@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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `schema/workflows/*.toml` — approval workflows (story 028). TOML in and out,
3
+ * strict as the type parser is, and canonical on the way out so the designer
4
+ * writes a file a person reads in a diff: `parseWorkflow(writeWorkflow(x))`
5
+ * equals `x`. The graph rules are `@crombolo/core`'s. `docs/workflows.md`.
6
+ */
7
+ import {
8
+ type SchemaWorkflow,
9
+ SEND_BACK_TO_EDITOR,
10
+ type StageRequirement,
11
+ WORKFLOW_COMMENTS,
12
+ WORKFLOW_ON_CHANGE,
13
+ WORKFLOW_ON_EDIT,
14
+ WORKFLOW_SELF_APPROVAL,
15
+ type WorkflowStage,
16
+ workflowGraphProblems,
17
+ } from '@crombolo/core'
18
+ import type { SchemaProblem } from './model.ts'
19
+ import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
20
+
21
+ const WORKFLOW_KEYS = [
22
+ 'key',
23
+ 'alias',
24
+ 'name',
25
+ 'applies-to',
26
+ 'publishers-may-skip',
27
+ 'on-edit',
28
+ 'self-approval',
29
+ 'comments',
30
+ 'unpublish',
31
+ 'on-change',
32
+ ] as const
33
+ const APPLIES_KEYS = ['branches', 'content-types'] as const
34
+ const STAGE_KEYS = [
35
+ 'alias',
36
+ 'name',
37
+ 'approvers',
38
+ 'required',
39
+ 'next',
40
+ 'send-back',
41
+ 'position',
42
+ ] as const
43
+ const POSITION_KEYS = ['x', 'y'] as const
44
+
45
+ function choice<T extends string>(
46
+ r: Reader,
47
+ t: Toml,
48
+ key: string,
49
+ path: string,
50
+ allowed: readonly T[],
51
+ fallback: T,
52
+ ): T {
53
+ const value = r.str(t, key, path)
54
+ if (value === undefined) return fallback
55
+ if (!(allowed as readonly string[]).includes(value)) {
56
+ r.problem(`${path}.${key}`, `expected one of ${allowed.join(', ')}`)
57
+ return fallback
58
+ }
59
+ return value as T
60
+ }
61
+
62
+ function requirement(r: Reader, t: Toml, path: string): StageRequirement {
63
+ const value = t.required
64
+ if (value === undefined) return 'one'
65
+ if (typeof value === 'number') return value
66
+ if (value === 'one' || value === 'majority' || value === 'all') return value
67
+ r.problem(`${path}.required`, 'expected one, majority, all, or a number')
68
+ return 'one'
69
+ }
70
+
71
+ function stage(r: Reader, t: Toml, path: string): WorkflowStage {
72
+ r.unknown(t, STAGE_KEYS, path)
73
+ let position: WorkflowStage['position']
74
+ if (t.position !== undefined) {
75
+ const p = r.table(t.position, `${path}.position`)
76
+ if (p) {
77
+ r.unknown(p, POSITION_KEYS, `${path}.position`)
78
+ position = {
79
+ x: r.num(p, 'x', `${path}.position`) ?? 0,
80
+ y: r.num(p, 'y', `${path}.position`) ?? 0,
81
+ }
82
+ }
83
+ }
84
+ return {
85
+ alias: r.str(t, 'alias', path, true) ?? '',
86
+ ...(r.str(t, 'name', path) !== undefined ? { name: r.str(t, 'name', path) } : {}),
87
+ approvers: r.strs(t, 'approvers', path),
88
+ required: requirement(r, t, path),
89
+ next: r.strs(t, 'next', path),
90
+ sendBack: r.str(t, 'send-back', path) ?? SEND_BACK_TO_EDITOR,
91
+ ...(position ? { position } : {}),
92
+ }
93
+ }
94
+
95
+ export function parseWorkflow(file: string, source: string): ParseResult<SchemaWorkflow> {
96
+ const r = new Reader(file)
97
+ const root = toml(source, r)
98
+ if (!root) return { value: undefined, problems: r.problems }
99
+ r.unknown(root, ['workflow', 'stage'], '')
100
+ const t = r.table(root.workflow, 'workflow')
101
+ if (!t) {
102
+ r.problem('workflow', 'a [workflow] table is required')
103
+ return { value: undefined, problems: r.problems }
104
+ }
105
+ const path = 'workflow'
106
+ r.unknown(t, WORKFLOW_KEYS, path)
107
+ let appliesTo = { branches: [] as string[], contentTypes: [] as string[] }
108
+ if (t['applies-to'] !== undefined) {
109
+ const a = r.table(t['applies-to'], `${path}.applies-to`)
110
+ if (a) {
111
+ r.unknown(a, APPLIES_KEYS, `${path}.applies-to`)
112
+ appliesTo = {
113
+ branches: r.strs(a, 'branches', `${path}.applies-to`).map((k) => k.toLowerCase()),
114
+ contentTypes: r.strs(a, 'content-types', `${path}.applies-to`),
115
+ }
116
+ }
117
+ }
118
+ const value: SchemaWorkflow = {
119
+ ...(r.str(t, 'key', path) !== undefined ? { key: r.str(t, 'key', path) } : {}),
120
+ alias: r.str(t, 'alias', path, true) ?? '',
121
+ name: r.str(t, 'name', path, true) ?? '',
122
+ appliesTo,
123
+ publishersMaySkip: r.bool(t, 'publishers-may-skip', path, true),
124
+ onEdit: choice(r, t, 'on-edit', path, WORKFLOW_ON_EDIT, 'withdraw'),
125
+ selfApproval: choice(r, t, 'self-approval', path, WORKFLOW_SELF_APPROVAL, 'refused'),
126
+ comments: choice(r, t, 'comments', path, WORKFLOW_COMMENTS, 'optional'),
127
+ unpublish: r.bool(t, 'unpublish', path, false),
128
+ onChange: choice(r, t, 'on-change', path, WORKFLOW_ON_CHANGE, 'keep'),
129
+ stages: r.tables(root.stage, 'stage').map((s, i) => stage(r, s, `stage[${i}]`)),
130
+ }
131
+ if (r.problems.length === 0)
132
+ for (const problem of workflowGraphProblems(value)) r.problem(problem.path, problem.message)
133
+ return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
134
+ }
135
+
136
+ const str = (value: string) => JSON.stringify(value)
137
+ const list = (values: readonly string[]) => `[${values.map(str).join(', ')}]`
138
+
139
+ /** A workflow as canonical TOML: a fixed key order, defaults written out. */
140
+ export function writeWorkflow(workflow: SchemaWorkflow): string {
141
+ const out = ['[workflow]']
142
+ if (workflow.key) out.push(`key = ${str(workflow.key)}`)
143
+ out.push(`alias = ${str(workflow.alias)}`, `name = ${str(workflow.name)}`)
144
+ out.push(
145
+ `applies-to = { branches = ${list(workflow.appliesTo.branches)}, content-types = ${list(workflow.appliesTo.contentTypes)} }`,
146
+ `publishers-may-skip = ${workflow.publishersMaySkip}`,
147
+ `on-edit = ${str(workflow.onEdit)}`,
148
+ `self-approval = ${str(workflow.selfApproval)}`,
149
+ `comments = ${str(workflow.comments)}`,
150
+ `unpublish = ${workflow.unpublish}`,
151
+ `on-change = ${str(workflow.onChange)}`,
152
+ )
153
+ for (const s of workflow.stages) {
154
+ out.push('', '[[stage]]', `alias = ${str(s.alias)}`)
155
+ if (s.name !== undefined) out.push(`name = ${str(s.name)}`)
156
+ out.push(
157
+ `approvers = ${list(s.approvers)}`,
158
+ `required = ${typeof s.required === 'number' ? s.required : str(s.required)}`,
159
+ `next = ${list(s.next)}`,
160
+ `send-back = ${str(s.sendBack)}`,
161
+ )
162
+ if (s.position) out.push(`position = { x = ${s.position.x}, y = ${s.position.y} }`)
163
+ }
164
+ return `${out.join('\n')}\n`
165
+ }
166
+
167
+ /**
168
+ * What the set of workflows gets wrong together: two with the same alias, or
169
+ * two claiming the same branch or content type, or naming a content type the
170
+ * schema does not have. Pure.
171
+ */
172
+ export function validateWorkflows(
173
+ workflows: readonly SchemaWorkflow[],
174
+ contentTypeAliases: ReadonlySet<string>,
175
+ ): SchemaProblem[] {
176
+ const problems: SchemaProblem[] = []
177
+ const file = (w: SchemaWorkflow) => `schema/workflows/${w.alias}.toml`
178
+ const aliases = new Map<string, string>()
179
+ const branches = new Map<string, string>()
180
+ const types = new Map<string, string>()
181
+ for (const w of workflows) {
182
+ if (aliases.has(w.alias))
183
+ problems.push({
184
+ file: file(w),
185
+ path: 'workflow.alias',
186
+ message: `two workflows are called "${w.alias}"`,
187
+ })
188
+ aliases.set(w.alias, w.alias)
189
+ for (const branch of w.appliesTo.branches) {
190
+ const other = branches.get(branch)
191
+ if (other)
192
+ problems.push({
193
+ file: file(w),
194
+ path: 'workflow.applies-to.branches',
195
+ message: `the branch ${branch} is covered by "${other}" already`,
196
+ })
197
+ branches.set(branch, w.alias)
198
+ }
199
+ for (const type of w.appliesTo.contentTypes) {
200
+ const other = types.get(type)
201
+ if (other)
202
+ problems.push({
203
+ file: file(w),
204
+ path: 'workflow.applies-to.content-types',
205
+ message: `the content type "${type}" is covered by "${other}" already`,
206
+ })
207
+ types.set(type, w.alias)
208
+ if (!contentTypeAliases.has(type))
209
+ problems.push({
210
+ file: file(w),
211
+ path: 'workflow.applies-to.content-types',
212
+ message: `there is no content type "${type}"`,
213
+ })
214
+ }
215
+ }
216
+ return problems
217
+ }
218
+
219
+ /**
220
+ * A workflow's version: a hash of its canonical file, which a request remembers.
221
+ * Where the designer drew a stage is left out, so moving a box is not a change. Pure.
222
+ */
223
+ export function workflowVersion(workflow: SchemaWorkflow): string {
224
+ const meaning = {
225
+ ...workflow,
226
+ stages: workflow.stages.map(({ position: _position, ...stage }) => stage),
227
+ }
228
+ return new Bun.CryptoHasher('sha256').update(writeWorkflow(meaning)).digest('hex').slice(0, 16)
229
+ }
@@ -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
 
@@ -162,8 +163,10 @@ export function writeLanguages(languages: readonly SchemaLanguage[]): string {
162
163
  return `${out.join('\n')}\n`
163
164
  }
164
165
 
165
- export function writeSchemaVersion(version: string): string {
166
- 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
167
170
  }
168
171
 
169
172
  /** The canonical filename for an alias: kebab-case. */