@crombolo/schema 0.11.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/check.ts +53 -20
- package/src/export.ts +4 -1
- package/src/forms.ts +32 -326
- package/src/index.ts +1 -0
- package/src/load.ts +45 -6
- package/src/model.ts +11 -1
- package/src/parse-forms.ts +54 -21
- package/src/parse.ts +26 -2
- package/src/sync.ts +36 -1
- package/src/validate-forms.ts +78 -10
- package/src/validate.ts +8 -0
- package/src/workflows.ts +229 -0
- package/src/write-forms.ts +9 -0
- package/src/write.ts +5 -2
package/src/workflows.ts
ADDED
|
@@ -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
|
+
}
|
package/src/write-forms.ts
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
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. */
|