@crombolo/schema 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Whole-set validation. Every problem is reported, not just the first, because
3
+ * a person fixing a schema wants the full list.
4
+ */
5
+ import { SYSTEM_MEDIA_TYPES, SYSTEM_MEMBER_TYPE } from '@crombolo/data'
6
+ import {
7
+ BUILTIN_DATA_TYPE_ALIASES,
8
+ checkSystemContentTypeFile,
9
+ declaredProperties,
10
+ SYSTEM_CONTENT_TYPE_ALIASES,
11
+ SYSTEM_MEDIA_TYPE_ALIASES,
12
+ } from './builtin.ts'
13
+ import {
14
+ allProperties,
15
+ type SchemaContentType,
16
+ type SchemaProblem,
17
+ type SchemaSet,
18
+ TYPE_KIND_FILES,
19
+ } from './model.ts'
20
+ import { type ValidateFormsOptions, validateForms } from './validate-forms.ts'
21
+
22
+ export interface ValidateOptions extends ValidateFormsOptions {
23
+ /** Template aliases that exist as view files; omit to skip template checks. */
24
+ componentAliases?: ReadonlySet<string>
25
+ }
26
+
27
+ const VERSION = /^\d+(\.\d+)*$/
28
+
29
+ export function validateSchemaSet(set: SchemaSet, options: ValidateOptions = {}): SchemaProblem[] {
30
+ const problems: SchemaProblem[] = []
31
+ const add = (file: string, path: string, message: string) =>
32
+ problems.push({ file, path, message })
33
+
34
+ if (!VERSION.test(set.version))
35
+ add('schema/schema.toml', 'schema.version', `"${set.version}" is not a dotted number`)
36
+
37
+ const dataTypeAliases = new Set(BUILTIN_DATA_TYPE_ALIASES)
38
+ const seenDataTypes = new Set<string>()
39
+ for (const d of set.dataTypes) {
40
+ const file = `schema/data-types/${d.alias}`
41
+ if (seenDataTypes.has(d.alias))
42
+ add(file, 'data-type.alias', `duplicate data type alias "${d.alias}"`)
43
+ seenDataTypes.add(d.alias)
44
+ dataTypeAliases.add(d.alias)
45
+ if (d.since && !VERSION.test(d.since))
46
+ add(file, 'data-type.since', `"${d.since}" is not a dotted number`)
47
+ }
48
+
49
+ // Both kinds share one alias space (Umbraco's content_type.alias is unique);
50
+ // compositions and allowed children stay within a kind.
51
+ const typeAliases = new Set<string>()
52
+ const keys = new Map<string, string>()
53
+ const kinds: Array<{ types: readonly SchemaContentType[]; header: string; dir: string }> = [
54
+ { types: set.contentTypes, ...TYPE_KIND_FILES.document },
55
+ { types: set.mediaTypes ?? [], ...TYPE_KIND_FILES.media },
56
+ { types: set.memberTypes ?? [], ...TYPE_KIND_FILES.member },
57
+ ]
58
+ for (const { types, header, dir } of kinds) {
59
+ for (const t of types) {
60
+ const file = `schema/${dir}/${t.alias}`
61
+ // A system type's alias belongs to it; a file may override it only by its key.
62
+ const system = SYSTEM_MEDIA_TYPES.find((s) => s.alias.toLowerCase() === t.alias.toLowerCase())
63
+ if (system && (dir !== 'media-types' || (t.key && t.key.toLowerCase() !== system.key)))
64
+ add(
65
+ file,
66
+ `${header}.alias`,
67
+ `"${t.alias}" is the system media type ${system.alias}; choose another alias, or override ${system.alias} with key "${system.key}"`,
68
+ )
69
+ const isSystemMember = t.alias.toLowerCase() === SYSTEM_MEMBER_TYPE.alias.toLowerCase()
70
+ if (
71
+ isSystemMember &&
72
+ (dir !== 'member-types' || (t.key && t.key.toLowerCase() !== SYSTEM_MEMBER_TYPE.key))
73
+ )
74
+ add(
75
+ file,
76
+ `${header}.alias`,
77
+ `"${t.alias}" is the built-in member type; choose another alias, or override it with key "${SYSTEM_MEMBER_TYPE.key}"`,
78
+ )
79
+ if (typeAliases.has(t.alias))
80
+ add(file, `${header}.alias`, `duplicate type alias "${t.alias}"`)
81
+ typeAliases.add(t.alias)
82
+ if (t.key) {
83
+ const owner = keys.get(t.key)
84
+ if (owner) add(file, `${header}.key`, `key already used by "${owner}"`)
85
+ keys.set(t.key, t.alias)
86
+ }
87
+ if (t.since && !VERSION.test(t.since))
88
+ add(file, `${header}.since`, `"${t.since}" is not a dotted number`)
89
+ const propertyAliases = new Set<string>()
90
+ for (const p of allProperties(t)) {
91
+ const path = `property "${p.alias}"`
92
+ if (dir !== 'member-types' && (p.memberCanView || p.memberCanEdit || p.sensitive))
93
+ add(file, path, 'member-can-view, member-can-edit and sensitive belong to member types')
94
+ if (propertyAliases.has(p.alias)) add(file, path, 'duplicate property alias')
95
+ propertyAliases.add(p.alias)
96
+ if (!dataTypeAliases.has(p.type)) add(file, `${path}.type`, `unknown data type "${p.type}"`)
97
+ if (p.key) {
98
+ const owner = keys.get(p.key)
99
+ if (owner) add(file, `${path}.key`, `key already used by "${owner}"`)
100
+ keys.set(p.key, `${t.alias}.${p.alias}`)
101
+ }
102
+ if (p.since && !VERSION.test(p.since))
103
+ add(file, `${path}.since`, `"${p.since}" is not a dotted number`)
104
+ if (p.variesByCulture && !t.variesByCulture)
105
+ add(
106
+ file,
107
+ `${path}.varies-by-culture`,
108
+ 'the type does not vary by culture, so the property cannot',
109
+ )
110
+ }
111
+ // An element type is a property bag with no URL, so the things a route needs
112
+ // do not apply to it. A media-type file rejects these outright because they
113
+ // are not in its vocabulary at all; here they are valid keys made invalid by
114
+ // `is-element`, so the rule lives at this level rather than in the parser.
115
+ // Silently ignoring them is worse: the file says one thing and the editor
116
+ // another.
117
+ if (t.isElement) {
118
+ if (t.components.length > 0)
119
+ add(file, `${header}.components`, 'an element type has no component: it is never routed')
120
+ if (t.defaultComponent)
121
+ add(
122
+ file,
123
+ `${header}.default-component`,
124
+ 'an element type has no template: it is never routed',
125
+ )
126
+ if (t.allowAtRoot)
127
+ add(
128
+ file,
129
+ `${header}.allow-at-root`,
130
+ 'an element type is not created in the content tree; use allow-in-library',
131
+ )
132
+ if (t.allowChildren.length > 0)
133
+ add(
134
+ file,
135
+ `${header}.allow-children`,
136
+ 'an element type has no children: it is not in the content tree',
137
+ )
138
+ }
139
+ if (t.defaultComponent && !t.components.includes(t.defaultComponent))
140
+ add(file, `${header}.default-component`, `"${t.defaultComponent}" is not in components`)
141
+ if (options.componentAliases) {
142
+ for (const alias of t.components)
143
+ if (!options.componentAliases.has(alias))
144
+ add(file, `${header}.components`, `no components/${alias}.tsx`)
145
+ }
146
+ }
147
+
148
+ // References between types, once every alias is known. First occurrence wins,
149
+ // so a duplicate alias (already reported) cannot shadow the real definition.
150
+ const byAlias = new Map<string, SchemaContentType>()
151
+ for (const t of types) if (!byAlias.has(t.alias)) byAlias.set(t.alias, t)
152
+ // System types exist whether or not a file defines them.
153
+ const known = (alias: string) =>
154
+ byAlias.has(alias) ||
155
+ (dir === TYPE_KIND_FILES.media.dir && SYSTEM_MEDIA_TYPE_ALIASES.has(alias)) ||
156
+ (dir === TYPE_KIND_FILES.document.dir && SYSTEM_CONTENT_TYPE_ALIASES.has(alias))
157
+ for (const t of types) {
158
+ const file = `schema/${dir}/${t.alias}`
159
+ if (dir === TYPE_KIND_FILES.document.dir)
160
+ for (const message of checkSystemContentTypeFile(t)) add(file, header, message)
161
+ for (const c of t.compositions) {
162
+ if (!known(c)) add(file, `${header}.compositions`, `unknown type "${c}"`)
163
+ // Values are stored by the property's own variance, so a culture-varying
164
+ // field on a type that does not vary would split one page's value by language.
165
+ const composed = byAlias.get(c)
166
+ if (
167
+ composed &&
168
+ !t.variesByCulture &&
169
+ declaredProperties(composed).some((p) => p.variesByCulture)
170
+ )
171
+ add(
172
+ file,
173
+ `${header}.compositions`,
174
+ `"${c}" has fields that vary by culture, so this type must vary by culture too`,
175
+ )
176
+ }
177
+ for (const c of t.allowChildren)
178
+ if (!known(c)) add(file, `${header}.allow-children`, `unknown type "${c}"`)
179
+ if (t.collection && !dataTypeAliases.has(t.collection))
180
+ add(file, `${header}.collection`, `unknown data type "${t.collection}"`)
181
+ }
182
+ for (const t of types) {
183
+ const cycle = findCompositionCycle(t.alias, byAlias)
184
+ if (cycle)
185
+ add(
186
+ `schema/${dir}/${t.alias}`,
187
+ `${header}.compositions`,
188
+ `composition cycle: ${cycle.join(' -> ')}`,
189
+ )
190
+ }
191
+ }
192
+
193
+ const defaults = set.languages.filter((l) => l.default)
194
+ if (set.languages.length > 0 && defaults.length !== 1)
195
+ add(
196
+ 'schema/languages.toml',
197
+ 'language',
198
+ `exactly one language must be default; found ${defaults.length}`,
199
+ )
200
+ const isos = new Set<string>()
201
+ for (const l of set.languages) {
202
+ if (isos.has(l.iso.toLowerCase()))
203
+ add('schema/languages.toml', `language "${l.iso}"`, 'duplicate language')
204
+ isos.add(l.iso.toLowerCase())
205
+ }
206
+ for (const l of set.languages) {
207
+ if (l.fallback && !isos.has(l.fallback.toLowerCase()))
208
+ add(
209
+ 'schema/languages.toml',
210
+ `language "${l.iso}".fallback`,
211
+ `unknown language "${l.fallback}"`,
212
+ )
213
+ }
214
+ // Forms are validated against the types they reach into, so this happens
215
+ // after the type aliases are known.
216
+ problems.push(
217
+ ...validateForms(set.forms ?? [], {
218
+ ...options,
219
+ contentTypeAliases:
220
+ options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
221
+ }),
222
+ )
223
+ return problems
224
+ }
225
+
226
+ function findCompositionCycle(
227
+ start: string,
228
+ byAlias: Map<string, { compositions: string[] }>,
229
+ ): string[] | undefined {
230
+ const walk = (alias: string, trail: string[]): string[] | undefined => {
231
+ if (trail.includes(alias)) return [...trail.slice(trail.indexOf(alias)), alias]
232
+ const type = byAlias.get(alias)
233
+ if (!type) return undefined
234
+ for (const next of type.compositions) {
235
+ const found = walk(next, [...trail, alias])
236
+ if (found) return found
237
+ }
238
+ return undefined
239
+ }
240
+ const found = walk(start, [])
241
+ return found && found[0] === start ? found : undefined
242
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Value migrations: one construct for a site's `schema/migrations/*.ts` and
3
+ * for the converters the framework ships. `convert` returns the new value —
4
+ * or, for a split, an object keyed by target property — and throws to say a
5
+ * value needs a person. docs/10, "Site-authored value migrations".
6
+ */
7
+ import { existsSync, readdirSync } from 'node:fs'
8
+ import { basename, join } from 'node:path'
9
+
10
+ export interface ValueMigrationContext {
11
+ documentKey: string
12
+ culture: string | null
13
+ segment: string | null
14
+ }
15
+
16
+ export interface ValueMigration {
17
+ /** Ledger name; the file name by default. */
18
+ id: string
19
+ /** True when the definition named itself; the loader keeps such an id. */
20
+ explicitId?: boolean
21
+ /** Applies from this schema version on; informational. */
22
+ since?: string
23
+ from: { type: string; property: string }
24
+ to: Array<{ property: string }>
25
+ convert(value: unknown, context: ValueMigrationContext): unknown
26
+ }
27
+
28
+ export interface ValueMigrationDefinition {
29
+ id?: string
30
+ since?: string
31
+ from: { type: string; property: string }
32
+ to: { property: string } | Array<{ property: string }>
33
+ convert(value: unknown, context: ValueMigrationContext): unknown
34
+ }
35
+
36
+ export function defineValueMigration(definition: ValueMigrationDefinition): ValueMigration {
37
+ const to = Array.isArray(definition.to) ? definition.to : [definition.to]
38
+ if (to.length === 0) throw new Error('a value migration needs at least one target property')
39
+ const migration: ValueMigration = {
40
+ id:
41
+ definition.id ??
42
+ `${definition.from.type}.${definition.from.property}->${to.map((t) => t.property).join('+')}`,
43
+ explicitId: definition.id !== undefined,
44
+ from: definition.from,
45
+ to,
46
+ convert: definition.convert,
47
+ }
48
+ if (definition.since) migration.since = definition.since
49
+ return migration
50
+ }
51
+
52
+ /** Normalises `convert`'s result to one value per target property. */
53
+ export function outputsOf(migration: ValueMigration, result: unknown): Map<string, unknown> {
54
+ const out = new Map<string, unknown>()
55
+ if (migration.to.length === 1) {
56
+ const single = migration.to[0]?.property as string
57
+ const record = result as Record<string, unknown> | null
58
+ out.set(
59
+ single,
60
+ record && typeof record === 'object' && !Array.isArray(record) && single in record
61
+ ? record[single]
62
+ : result,
63
+ )
64
+ return out
65
+ }
66
+ const record = (result ?? {}) as Record<string, unknown>
67
+ for (const target of migration.to) out.set(target.property, record[target.property] ?? null)
68
+ return out
69
+ }
70
+
71
+ /** Every `schema/migrations/*.ts` default export, sorted by file name. */
72
+ export async function loadValueMigrations(schemaDir: string): Promise<ValueMigration[]> {
73
+ const dir = join(schemaDir, 'migrations')
74
+ if (!existsSync(dir)) return []
75
+ const out: ValueMigration[] = []
76
+ for (const name of readdirSync(dir)
77
+ .filter((n) => /\.(ts|js)$/.test(n))
78
+ .sort()) {
79
+ const module = (await import(join(dir, name))) as {
80
+ default?: ValueMigration | ValueMigrationDefinition
81
+ }
82
+ const value = module.default
83
+ if (!value) throw new Error(`schema/migrations/${name} has no default export`)
84
+ const migration =
85
+ 'id' in value && Array.isArray((value as ValueMigration).to)
86
+ ? (value as ValueMigration)
87
+ : defineValueMigration(value as ValueMigrationDefinition)
88
+ if (!migration.explicitId) migration.id = basename(name).replace(/\.(ts|js)$/, '')
89
+ out.push(migration)
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** A framework converter between two property editors' stored formats. */
95
+ export interface ValueConverter {
96
+ from: string
97
+ to: string
98
+ convert(value: unknown, context: ValueMigrationContext): unknown
99
+ }
100
+
101
+ const converters = new Map<string, ValueConverter>()
102
+ const key = (from: string, to: string) => `${from}->${to}`
103
+
104
+ export function registerConverter(converter: ValueConverter): void {
105
+ converters.set(key(converter.from, converter.to), converter)
106
+ }
107
+
108
+ export function findConverter(from: string, to: string): ValueConverter | undefined {
109
+ if (from === to) return { from, to, convert: (v) => v }
110
+ return converters.get(key(from, to))
111
+ }
112
+
113
+ // Editors whose stored text is the same shape: a plain copy converts.
114
+ for (const [from, to] of [
115
+ ['Umbraco.TextBox', 'Umbraco.TextArea'],
116
+ ['Umbraco.TextArea', 'Umbraco.TextBox'],
117
+ ['Umbraco.TextBox', 'Umbraco.RichText'],
118
+ ['Umbraco.TextArea', 'Umbraco.RichText'],
119
+ ['Umbraco.TextBox', 'Umbraco.Label'],
120
+ ]) {
121
+ registerConverter({ from: from as string, to: to as string, convert: (v) => v })
122
+ }
package/src/version.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The site's schema version, and what a change does to it.
3
+ *
4
+ * A developer owns this number when schema arrives by deploy. When an editor
5
+ * changes a type on a running site the number still has to move, for two
6
+ * reasons: `compareStates` reads it first, so it is how other nodes learn they
7
+ * are behind, and a production sync refuses a changed hash at an unchanged
8
+ * version — the guard that makes silent drift impossible.
9
+ *
10
+ * How far it moves is decided by the same check that gates an upgrade, so the
11
+ * number means something: a major says content had to be converted to get here.
12
+ */
13
+ import type { ChangeClass } from './check.ts'
14
+
15
+ /** `1.4.2` → `[1, 4, 2]`, tolerant of a short or over-long version. */
16
+ function parts(version: string): [number, number, number] {
17
+ // `??` will not do: parseInt('') is NaN, which is neither null nor undefined,
18
+ // and `NaN + 1` would spread through the whole version.
19
+ const at = (index: number): number => {
20
+ const parsed = Number.parseInt(version.split('.')[index] ?? '', 10)
21
+ return Number.isFinite(parsed) ? parsed : 0
22
+ }
23
+ return [at(0), at(1), at(2)]
24
+ }
25
+
26
+ /**
27
+ * The version a change should land on.
28
+ *
29
+ * - `breaking` — a property's editor changed under live content, or a type with
30
+ * content was removed: **major**, because getting here converted data.
31
+ * - `data-requiring` — a new mandatory property, or a value migration to run:
32
+ * **minor**. Content has to be filled in, but nothing was lost.
33
+ * - `additive` — a new type or property: **minor**.
34
+ * - `none` — nothing to apply, so nothing to bump.
35
+ *
36
+ * A major resets what is below it, as semver does, so 1.4.2 breaking is 2.0.0
37
+ * rather than 2.4.2.
38
+ */
39
+ export function nextSchemaVersion(current: string, classification: ChangeClass): string {
40
+ const [major, minor, patch] = parts(current)
41
+ switch (classification) {
42
+ case 'breaking':
43
+ return `${major + 1}.0.0`
44
+ case 'data-requiring':
45
+ case 'additive':
46
+ return `${major}.${minor + 1}.0`
47
+ default:
48
+ return `${major}.${minor}.${patch}`
49
+ }
50
+ }
51
+
52
+ /** Whether a classification moves the version at all. */
53
+ export const bumps = (classification: ChangeClass): boolean => classification !== 'none'
@@ -0,0 +1,139 @@
1
+ /**
2
+ * A form definition → canonical TOML.
3
+ *
4
+ * Same contract as `write.ts`: deterministic key order, one layout, and
5
+ * `parseForm(writeForm(x))` equal to `x`. Held to that by the suite, which is
6
+ * what lets the backoffice write a file a person will read in a diff.
7
+ */
8
+ import type {
9
+ FormCondition,
10
+ SchemaForm,
11
+ SchemaFormField,
12
+ SchemaFormGroup,
13
+ SchemaFormPage,
14
+ SchemaFormWorkflow,
15
+ } from '@crombolo/core'
16
+
17
+ function str(value: string): string {
18
+ return JSON.stringify(value)
19
+ }
20
+
21
+ function scalar(value: unknown): string {
22
+ if (typeof value === 'string') return str(value)
23
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
24
+ if (Array.isArray(value)) return `[${value.map(scalar).join(', ')}]`
25
+ if (value === null || value === undefined) return '""'
26
+ return str(JSON.stringify(value))
27
+ }
28
+
29
+ /** Workflow settings arrive from the API, so a key may need quoting. */
30
+ function keyOf(key: string): string {
31
+ return /^[A-Za-z0-9_-]+$/.test(key) ? key : str(key)
32
+ }
33
+
34
+ function line(out: string[], key: string, value: unknown, when = true): void {
35
+ if (!when || value === undefined) return
36
+ out.push(`${keyOf(key)} = ${scalar(value)}`)
37
+ }
38
+
39
+ /**
40
+ * The rules on one line, as an array of inline tables.
41
+ *
42
+ * A condition is short and reads better as one line than as five tables, and
43
+ * `[[…rule]]` would otherwise have to be ordered against the sub-tables around
44
+ * it — which is the kind of TOML that is easy to write and hard to read.
45
+ */
46
+ function writeCondition(out: string[], header: string, condition: FormCondition): void {
47
+ out.push('', `[${header}]`)
48
+ line(out, 'action', condition.action)
49
+ line(out, 'match', condition.match)
50
+ const rules = condition.rules.map((rule) => {
51
+ const parts = [`field = ${str(rule.field)}`, `operator = ${str(rule.operator)}`]
52
+ if (rule.value !== undefined) parts.push(`value = ${str(rule.value)}`)
53
+ return `{ ${parts.join(', ')} }`
54
+ })
55
+ out.push(`rule = [${rules.join(', ')}]`)
56
+ }
57
+
58
+ function writeField(out: string[], header: string, field: SchemaFormField): void {
59
+ out.push('', `[[${header}]]`)
60
+ line(out, 'key', field.key)
61
+ line(out, 'alias', field.alias)
62
+ line(out, 'type', field.type)
63
+ line(out, 'caption', field.caption)
64
+ line(out, 'help-text', field.helpText)
65
+ // Consent defaults to mandatory, so the key is written only when it differs
66
+ // from what the parser would infer — otherwise a round trip would add it.
67
+ const mandatoryByDefault = field.type === 'dataConsent'
68
+ line(out, 'mandatory', field.mandatory, field.mandatory !== mandatoryByDefault)
69
+ line(out, 'mandatory-message', field.mandatoryMessage)
70
+ line(out, 'pattern', field.pattern)
71
+ line(out, 'pattern-message', field.patternMessage)
72
+ line(out, 'sensitive', true, field.sensitive)
73
+ line(out, 'css-class', field.cssClass)
74
+ line(out, 'autocomplete', field.autocomplete)
75
+ line(out, 'values', field.values, field.values.length > 0)
76
+ line(out, 'placeholder', field.placeholder)
77
+ line(out, 'default-value', field.defaultValue)
78
+ line(out, 'content', field.content)
79
+ line(out, 'maxlength', field.maxlength)
80
+ line(out, 'rows', field.rows)
81
+ line(out, 'min', field.min)
82
+ line(out, 'max', field.max)
83
+ line(out, 'step', field.step)
84
+ line(out, 'accept', field.accept, field.accept.length > 0)
85
+ line(out, 'max-size-kb', field.maxSizeKb)
86
+ line(out, 'multiple', field.multiple)
87
+ if (field.condition) writeCondition(out, `${header}.condition`, field.condition)
88
+ }
89
+
90
+ function writeGroup(out: string[], header: string, group: SchemaFormGroup): void {
91
+ out.push('', `[[${header}]]`)
92
+ line(out, 'caption', group.caption)
93
+ line(out, 'columns', group.columns, group.columns !== 1)
94
+ if (group.condition) writeCondition(out, `${header}.condition`, group.condition)
95
+ for (const field of group.fields) writeField(out, `${header}.field`, field)
96
+ }
97
+
98
+ function writePage(out: string[], page: SchemaFormPage): void {
99
+ out.push('', '[[page]]')
100
+ line(out, 'caption', page.caption)
101
+ if (page.condition) writeCondition(out, 'page.condition', page.condition)
102
+ for (const group of page.groups) writeGroup(out, 'page.group', group)
103
+ }
104
+
105
+ function writeWorkflow(out: string[], workflow: SchemaFormWorkflow): void {
106
+ out.push('', '[[workflow]]')
107
+ line(out, 'key', workflow.key)
108
+ line(out, 'type', workflow.type)
109
+ line(out, 'name', workflow.name)
110
+ line(out, 'on', workflow.on, workflow.on !== 'submit')
111
+ const keys = Object.keys(workflow.settings).sort()
112
+ if (keys.length > 0) {
113
+ out.push('', '[workflow.settings]')
114
+ for (const key of keys) line(out, key, workflow.settings[key])
115
+ }
116
+ }
117
+
118
+ export function writeForm(form: SchemaForm): string {
119
+ const out: string[] = ['[form]']
120
+ line(out, 'key', form.key)
121
+ line(out, 'alias', form.alias)
122
+ line(out, 'name', form.name)
123
+ line(out, 'notes', form.notes)
124
+ line(out, 'store-entries', false, !form.storeEntries)
125
+ line(out, 'requires-approval', true, form.requiresApproval)
126
+ line(out, 'submit-label', form.submitLabel)
127
+ line(out, 'next-label', form.nextLabel)
128
+ line(out, 'previous-label', form.previousLabel)
129
+ line(out, 'message-on-submit', form.messageOnSubmit)
130
+ line(out, 'redirect-to', form.redirectTo)
131
+ line(out, 'theme', form.theme)
132
+ line(out, 'honeypot', false, !form.honeypot)
133
+ line(out, 'minimum-submit-seconds', form.minimumSubmitSeconds)
134
+ line(out, 'max-entries', form.maxEntries)
135
+ line(out, 'since', form.since)
136
+ for (const page of form.pages) writePage(out, page)
137
+ for (const workflow of form.workflows) writeWorkflow(out, workflow)
138
+ return `${out.join('\n')}\n`
139
+ }