@texuli/spec 0.3.0 → 0.4.0
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/README.md +80 -14
- package/dist/build.d.ts +5 -0
- package/dist/build.js +1 -0
- package/dist/canonical.js +20 -13
- package/dist/catalog.d.ts +9 -3
- package/dist/catalog.js +7 -1
- package/dist/codegen/index.d.ts +2 -2
- package/dist/codegen/index.js +2 -2
- package/dist/codegen/kotlin.d.ts +6 -0
- package/dist/codegen/kotlin.js +80 -8
- package/dist/conformance.d.ts +11 -2
- package/dist/conformance.js +93 -2
- package/dist/contract.d.ts +112 -0
- package/dist/contract.js +196 -0
- package/dist/dialect.d.ts +10 -1
- package/dist/dialect.js +6 -1
- package/dist/generated/typescript/blueprint.d.ts +103 -5
- package/dist/generated/typescript/blueprint.js +1 -1
- package/dist/generated/typescript/catalog-schema.d.ts +35 -0
- package/dist/generated/typescript/catalog-schema.js +1 -1
- package/dist/generated/typescript/diagnostics.d.ts +10 -1
- package/dist/generated/typescript/diagnostics.js +1 -1
- package/dist/generated/typescript/manifest.js +1 -1
- package/dist/generated/typescript/vocabulary.d.ts +152 -4
- package/dist/generated/typescript/vocabulary.js +27 -2
- package/dist/generated/typescript/workflow.d.ts +84 -0
- package/dist/generated/typescript/workflow.js +3 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +9 -3
- package/dist/migrations/0.2.0-0.3.0.d.ts +7 -0
- package/dist/migrations/0.2.0-0.3.0.js +10 -0
- package/dist/migrations/0.3.0-0.4.0.d.ts +9 -0
- package/dist/migrations/0.3.0-0.4.0.js +12 -0
- package/dist/migrations/index.d.ts +15 -0
- package/dist/migrations/index.js +53 -0
- package/dist/migrations/types.d.ts +14 -0
- package/dist/migrations/types.js +1 -0
- package/dist/surface.d.ts +60 -0
- package/dist/surface.js +162 -0
- package/dist/validate.d.ts +89 -14
- package/dist/validate.js +725 -64
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workflow.d.ts +15 -0
- package/dist/workflow.js +256 -0
- package/package.json +13 -1
package/dist/validate.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import { ACTION_FIELDS, contractRoot, DATA_BINDING_KEY, SPEC_ACTION_OPS, SPEC_LIMITS, SPEC_REGEX, SPEC_STATE_TYPES, STATE_BINDING_KEY, STATE_FIELDS, toDialectSchema, } from "./contract.js";
|
|
1
2
|
import { isRecord, validateInstance } from "./dialect.js";
|
|
3
|
+
import { checkStateSurface, surfaceInputs } from "./surface.js";
|
|
2
4
|
const MAX_TREE_DEPTH = 64;
|
|
5
|
+
const ACTION_KEYS = new Set(Object.values(ACTION_FIELDS).flatMap((f) => f.allowed));
|
|
6
|
+
const STATE_KEYS = new Set(Object.values(STATE_FIELDS).flat());
|
|
7
|
+
const FORM_ID = 'action-form';
|
|
8
|
+
const BINDING_SHAPE = 'A binding is exactly {"$state": name} or {"$data": name}.';
|
|
3
9
|
/**
|
|
4
10
|
* Validate a blueprint envelope against the spec alone: the envelope schema,
|
|
5
|
-
* then every node against the catalog
|
|
6
|
-
* names, prop kinds and enum values,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* the declared state and flow, then every node against the catalog
|
|
12
|
+
* vocabulary (closed ids, closed prop names, prop kinds and enum values,
|
|
13
|
+
* property schemas, required props, no platform-extension props from data,
|
|
14
|
+
* a `slot` that names a slot prop of the parent, bindings, two-way `bind`,
|
|
15
|
+
* `on` events and their closed actions) and the declared profile. A `when`
|
|
16
|
+
* condition is checked for shape only; the runtime formula parser checks
|
|
17
|
+
* its operands. With `catalogHash`, an envelope bound to another catalog is
|
|
18
|
+
* refused. Deprecated components and props add `warnings` (never failing).
|
|
19
|
+
* This is the shared, target-neutral contract; URL policy, limits, grammar
|
|
20
|
+
* and rendering stay in the runtime validators (`@texuli/catalog/validate`).
|
|
13
21
|
*/
|
|
14
22
|
export function validateBlueprint(input, documents, options = {}) {
|
|
15
23
|
const diagnostics = validateInstance(documents.blueprintSchema, input).map((issue) => ({ code: 'schema', path: issue.path, message: `${issue.code}: ${issue.message}` }));
|
|
@@ -22,49 +30,312 @@ export function validateBlueprint(input, documents, options = {}) {
|
|
|
22
30
|
message: 'The blueprint is bound to another catalog.',
|
|
23
31
|
});
|
|
24
32
|
}
|
|
33
|
+
const { workflowSchema: schema, validateWorkflowGraph: validate } = documents;
|
|
34
|
+
const workflow = schema && validate ? { schema, validate } : undefined;
|
|
35
|
+
const state = checkStateDeclarations(input.state, diagnostics, workflow);
|
|
36
|
+
const flow = checkFlow(input.flow, diagnostics);
|
|
37
|
+
const warnings = [];
|
|
25
38
|
const portable = input.profile === 'portable';
|
|
26
39
|
if (isRecord(input.tree)) {
|
|
27
|
-
|
|
40
|
+
// The state surface's node rules, as the player refuses them at paint time.
|
|
41
|
+
for (const issue of checkStateSurface(input.tree, surfaceInputs(input))) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
code: issue.code,
|
|
44
|
+
path: `$.tree${issue.at.map((index) => `.children[${index}]`).join('')}`,
|
|
45
|
+
message: issue.message,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
diagnostics.push(...validateTree(input.tree, documents.catalog, {
|
|
49
|
+
portable,
|
|
50
|
+
path: '$.tree',
|
|
51
|
+
state,
|
|
52
|
+
flow,
|
|
53
|
+
root: contractRoot(documents.blueprintSchema, workflow?.schema),
|
|
54
|
+
workflow,
|
|
55
|
+
warnings,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: diagnostics.length === 0,
|
|
60
|
+
diagnostics,
|
|
61
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// State and flow
|
|
66
|
+
/**
|
|
67
|
+
* Read the envelope's `state` (code `state`): at most 64 variables, safe
|
|
68
|
+
* names, the fields each type takes, and an initial value of the declared
|
|
69
|
+
* type. One diagnostic per refused declaration; a refused declaration stays
|
|
70
|
+
* declared (so bindings to it are not also `unknown-state`). Shape problems
|
|
71
|
+
* the schema reports are not repeated.
|
|
72
|
+
*/
|
|
73
|
+
export function checkStateDeclarations(input, out, workflow) {
|
|
74
|
+
const declared = new Map();
|
|
75
|
+
if (!isRecord(input))
|
|
76
|
+
return declared;
|
|
77
|
+
const names = Object.keys(input);
|
|
78
|
+
if (names.length > SPEC_LIMITS.stateVars) {
|
|
79
|
+
out.push({
|
|
80
|
+
code: 'state',
|
|
81
|
+
path: '$.state',
|
|
82
|
+
message: `At most ${SPEC_LIMITS.stateVars} state variables (got ${names.length}).`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
const path = `$.state.${name}`;
|
|
87
|
+
if (!SPEC_REGEX.stateName.test(name)) {
|
|
88
|
+
out.push({
|
|
89
|
+
code: 'state',
|
|
90
|
+
path,
|
|
91
|
+
message: `"${name}" is not a valid state name.`,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const raw = input[name];
|
|
96
|
+
if (!isRecord(raw) || !SPEC_STATE_TYPES.includes(raw.type))
|
|
97
|
+
continue;
|
|
98
|
+
const variable = readDeclaration(name, raw, path, out, workflow);
|
|
99
|
+
declared.set(name, variable);
|
|
100
|
+
}
|
|
101
|
+
return declared;
|
|
102
|
+
}
|
|
103
|
+
function readDeclaration(name, raw, path, out, workflow) {
|
|
104
|
+
const type = raw.type;
|
|
105
|
+
const writable = raw.source === undefined;
|
|
106
|
+
const refuse = (where, message) => {
|
|
107
|
+
out.push({ code: 'state', path: where, message });
|
|
108
|
+
return { type, valid: false, initial: undefined, writable };
|
|
109
|
+
};
|
|
110
|
+
const accept = (initial, bounds) => ({
|
|
111
|
+
type,
|
|
112
|
+
valid: true,
|
|
113
|
+
initial,
|
|
114
|
+
writable,
|
|
115
|
+
...bounds,
|
|
116
|
+
});
|
|
117
|
+
// Unknown keys are the schema's to report; only fields of another type are judged here.
|
|
118
|
+
const extra = Object.keys(raw).find((key) => STATE_KEYS.has(key) && !STATE_FIELDS[type].includes(key));
|
|
119
|
+
if (extra !== undefined)
|
|
120
|
+
return refuse(`${path}.${extra}`, `A ${type} variable takes no "${extra}".`);
|
|
121
|
+
const has = (key) => Object.hasOwn(raw, key);
|
|
122
|
+
switch (type) {
|
|
123
|
+
case 'boolean': {
|
|
124
|
+
const initial = has('initial') ? raw.initial : false;
|
|
125
|
+
if (typeof initial !== 'boolean')
|
|
126
|
+
return refuse(`${path}.initial`, `"${name}" needs a boolean.`);
|
|
127
|
+
return accept(initial);
|
|
128
|
+
}
|
|
129
|
+
case 'number': {
|
|
130
|
+
const { min, max } = raw;
|
|
131
|
+
if (!finite(min) || !finite(max) || min > max) {
|
|
132
|
+
return refuse(path, `"${name}" needs finite min ≤ max.`);
|
|
133
|
+
}
|
|
134
|
+
const initial = has('initial') ? raw.initial : min;
|
|
135
|
+
if (!finite(initial) || initial < min || initial > max) {
|
|
136
|
+
return refuse(`${path}.initial`, `"${name}" initial must be between ${min} and ${max}.`);
|
|
137
|
+
}
|
|
138
|
+
return accept(initial, { min, max });
|
|
139
|
+
}
|
|
140
|
+
case 'enum': {
|
|
141
|
+
const values = raw.values;
|
|
142
|
+
if (!Array.isArray(values))
|
|
143
|
+
return refuse(path, `"${name}" needs values.`);
|
|
144
|
+
if (new Set(values).size !== values.length) {
|
|
145
|
+
return refuse(`${path}.values`, `"${name}" values must be distinct.`);
|
|
146
|
+
}
|
|
147
|
+
const initial = has('initial') ? raw.initial : values[0];
|
|
148
|
+
if (typeof initial !== 'string' || !values.includes(initial)) {
|
|
149
|
+
return refuse(`${path}.initial`, `"${name}" initial must be one of its values.`);
|
|
150
|
+
}
|
|
151
|
+
return accept(initial, { values: values });
|
|
152
|
+
}
|
|
153
|
+
case 'string': {
|
|
154
|
+
const maxLength = has('maxLength')
|
|
155
|
+
? raw.maxLength
|
|
156
|
+
: SPEC_LIMITS.defaultStringLength;
|
|
157
|
+
const initial = has('initial') ? raw.initial : '';
|
|
158
|
+
if (typeof initial !== 'string' || initial.length > maxLength) {
|
|
159
|
+
return refuse(`${path}.initial`, `"${name}" initial must be a string of at most ${maxLength}.`);
|
|
160
|
+
}
|
|
161
|
+
return accept(initial, { maxLength });
|
|
162
|
+
}
|
|
163
|
+
case 'list': {
|
|
164
|
+
const maxItems = has('maxItems') ? raw.maxItems : SPEC_LIMITS.listItems;
|
|
165
|
+
const initial = has('initial') ? raw.initial : [];
|
|
166
|
+
const problem = listProblem(initial, maxItems);
|
|
167
|
+
if (problem)
|
|
168
|
+
return refuse(`${path}.initial${problem.at}`, problem.message);
|
|
169
|
+
return accept(initial, { maxItems });
|
|
170
|
+
}
|
|
171
|
+
case 'workflow': {
|
|
172
|
+
if (!has('initial'))
|
|
173
|
+
return refuse(path, `"${name}" needs an initial workflow graph.`);
|
|
174
|
+
if (!workflow) {
|
|
175
|
+
return refuse(`${path}.initial`, 'Workflow state needs the workflow schema and validator.');
|
|
176
|
+
}
|
|
177
|
+
const result = workflow.validate(raw.initial, workflow.schema);
|
|
178
|
+
for (const d of result.diagnostics) {
|
|
179
|
+
out.push({ ...d, path: `${path}.initial${d.path.slice(1)}` });
|
|
180
|
+
}
|
|
181
|
+
return { type, valid: result.ok, initial: raw.initial, writable };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A list value: at most `maxItems` items, each `{ id, ...fields }` with a
|
|
187
|
+
* unique item key and at most 16 scalar fields (string ≤ 1000, finite
|
|
188
|
+
* number, boolean) named like state variables. Strings follow the
|
|
189
|
+
* `listText` rule: a draggable item's text never hides or reorders itself.
|
|
190
|
+
*/
|
|
191
|
+
export function listProblem(value, maxItems) {
|
|
192
|
+
if (!Array.isArray(value))
|
|
193
|
+
return { at: '', message: 'A list value is an array of items.' };
|
|
194
|
+
if (value.length > maxItems)
|
|
195
|
+
return { at: '', message: `At most ${maxItems} items.` };
|
|
196
|
+
const ids = new Set();
|
|
197
|
+
for (const [index, item] of value.entries()) {
|
|
198
|
+
const at = `[${index}]`;
|
|
199
|
+
if (!isRecord(item) || typeof item.id !== 'string' || !SPEC_REGEX.itemKey.test(item.id)) {
|
|
200
|
+
return { at, message: 'An item is an object with an item key "id".' };
|
|
201
|
+
}
|
|
202
|
+
if (ids.has(item.id))
|
|
203
|
+
return { at: `${at}.id`, message: `Item "${item.id}" appears twice.` };
|
|
204
|
+
ids.add(item.id);
|
|
205
|
+
const fields = Object.keys(item).filter((key) => key !== 'id');
|
|
206
|
+
if (fields.length > SPEC_LIMITS.listItemFields) {
|
|
207
|
+
return { at, message: `At most ${SPEC_LIMITS.listItemFields} fields besides "id".` };
|
|
208
|
+
}
|
|
209
|
+
for (const key of fields) {
|
|
210
|
+
const field = item[key];
|
|
211
|
+
const ok = SPEC_REGEX.stateName.test(key) &&
|
|
212
|
+
(typeof field === 'boolean' ||
|
|
213
|
+
finite(field) ||
|
|
214
|
+
(typeof field === 'string' && field.length <= SPEC_LIMITS.stringLength));
|
|
215
|
+
if (!ok) {
|
|
216
|
+
return {
|
|
217
|
+
at: `${at}.${key}`,
|
|
218
|
+
message: 'Item fields are named scalars (strings of at most 1000).',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (typeof field === 'string' && !SPEC_REGEX.listText.test(field)) {
|
|
222
|
+
return {
|
|
223
|
+
at: `${at}.${key}`,
|
|
224
|
+
message: 'Item text cannot hold control, bidirectional or invisible formatting characters.',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
28
228
|
}
|
|
29
|
-
return
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Read the envelope's `flow` (code `state`): distinct views, an initial and
|
|
233
|
+
* transitions that name declared views (or `*` as a source).
|
|
234
|
+
*/
|
|
235
|
+
export function checkFlow(input, out) {
|
|
236
|
+
if (!isRecord(input) || !Array.isArray(input.views))
|
|
237
|
+
return null;
|
|
238
|
+
const views = input.views.filter((view) => typeof view === 'string');
|
|
239
|
+
const fail = (path, message) => out.push({ code: 'state', path, message });
|
|
240
|
+
if (new Set(views).size !== views.length)
|
|
241
|
+
fail('$.flow.views', 'Flow views must be distinct.');
|
|
242
|
+
if (input.initial !== undefined && !views.includes(input.initial)) {
|
|
243
|
+
fail('$.flow.initial', 'flow.initial must be one of the declared views.');
|
|
244
|
+
}
|
|
245
|
+
const events = new Set();
|
|
246
|
+
if (Array.isArray(input.transitions)) {
|
|
247
|
+
input.transitions.forEach((transition, index) => {
|
|
248
|
+
if (!isRecord(transition))
|
|
249
|
+
return;
|
|
250
|
+
const path = `$.flow.transitions[${index}]`;
|
|
251
|
+
const { from, to, on } = transition;
|
|
252
|
+
if (typeof from === 'string' && from !== '*' && !views.includes(from)) {
|
|
253
|
+
fail(`${path}.from`, 'from must be a declared view or "*".');
|
|
254
|
+
}
|
|
255
|
+
if (typeof to === 'string' && !views.includes(to))
|
|
256
|
+
fail(`${path}.to`, 'to must be a declared view.');
|
|
257
|
+
if (typeof on === 'string')
|
|
258
|
+
events.add(on);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return { views, events };
|
|
262
|
+
}
|
|
263
|
+
/** A context reporting into `out`. */
|
|
264
|
+
function reportInto(context) {
|
|
265
|
+
return {
|
|
266
|
+
...context,
|
|
267
|
+
fail: (code, path, message) => context.out.push({ code, path, id: context.id, message }),
|
|
268
|
+
};
|
|
30
269
|
}
|
|
31
270
|
/** Check one catalog tree against the vocabulary. See {@link validateBlueprint}. */
|
|
32
271
|
export function validateTree(tree, catalog, options = {}) {
|
|
33
272
|
const byId = new Map(catalog.components.map((c) => [c.id, c]));
|
|
34
273
|
const out = [];
|
|
35
|
-
const
|
|
274
|
+
const state = options.state ?? new Map();
|
|
275
|
+
const flow = options.flow ?? null;
|
|
276
|
+
const root = options.root ?? { $defs: {} };
|
|
277
|
+
const warn = (warning) => options.warnings?.push(warning);
|
|
278
|
+
const visit = (node, path, depth, parent, insideForm) => {
|
|
36
279
|
if (!isRecord(node) || typeof node.id !== 'string' || depth > MAX_TREE_DEPTH)
|
|
37
280
|
return;
|
|
38
281
|
checkSlot(node.slot, parent, node.id, path, out);
|
|
39
282
|
const component = byId.get(node.id);
|
|
283
|
+
const context = reportInto({
|
|
284
|
+
id: node.id,
|
|
285
|
+
state,
|
|
286
|
+
flow,
|
|
287
|
+
root,
|
|
288
|
+
insideForm,
|
|
289
|
+
out,
|
|
290
|
+
workflow: options.workflow,
|
|
291
|
+
});
|
|
40
292
|
if (!component) {
|
|
41
|
-
|
|
42
|
-
code: 'unknown-id',
|
|
43
|
-
path: `${path}.id`,
|
|
44
|
-
id: node.id,
|
|
45
|
-
message: `"${node.id}" is not in the catalog.`,
|
|
46
|
-
});
|
|
293
|
+
context.fail('unknown-id', `${path}.id`, `"${node.id}" is not in the catalog.`);
|
|
47
294
|
}
|
|
48
295
|
else {
|
|
49
296
|
if (options.portable && component.capability !== 'portable-core') {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
297
|
+
context.fail('unsupported-profile', path, `"${node.id}" is ${component.capability}, not portable core.`);
|
|
298
|
+
}
|
|
299
|
+
const props = isRecord(node.props) ? node.props : {};
|
|
300
|
+
const bind = isRecord(node.bind) ? node.bind : {};
|
|
301
|
+
checkProps(props, bind, component, `${path}.props`, context);
|
|
302
|
+
checkBind(bind, props, component, `${path}.bind`, context);
|
|
303
|
+
if (isRecord(node.on))
|
|
304
|
+
checkOn(node.on, component, `${path}.on`, context);
|
|
305
|
+
if (component.deprecated) {
|
|
306
|
+
warn(deprecation(`${path}.id`, node.id, `"${node.id}"`, component.deprecated));
|
|
307
|
+
}
|
|
308
|
+
for (const prop of component.props) {
|
|
309
|
+
if (!prop.deprecated)
|
|
310
|
+
continue;
|
|
311
|
+
const where = Object.hasOwn(props, prop.name)
|
|
312
|
+
? `${path}.props.${prop.name}`
|
|
313
|
+
: Object.hasOwn(bind, prop.name)
|
|
314
|
+
? `${path}.bind.${prop.name}`
|
|
315
|
+
: undefined;
|
|
316
|
+
if (where)
|
|
317
|
+
warn(deprecation(where, node.id, `"${prop.name}"`, prop.deprecated));
|
|
56
318
|
}
|
|
57
|
-
checkProps(node.props, component, `${path}.props`, out);
|
|
58
319
|
}
|
|
59
320
|
if (Array.isArray(node.children)) {
|
|
321
|
+
const inside = insideForm || node.id === FORM_ID;
|
|
60
322
|
node.children.forEach((child, index) => {
|
|
61
|
-
visit(child, `${path}.children[${index}]`, depth + 1, component);
|
|
323
|
+
visit(child, `${path}.children[${index}]`, depth + 1, component, inside);
|
|
62
324
|
});
|
|
63
325
|
}
|
|
64
326
|
};
|
|
65
|
-
visit(tree, options.path ?? '$', 0, null);
|
|
327
|
+
visit(tree, options.path ?? '$', 0, null, false);
|
|
66
328
|
return out;
|
|
67
329
|
}
|
|
330
|
+
function deprecation(path, id, what, info) {
|
|
331
|
+
const instead = info.replacement ? `; use "${info.replacement}"` : '';
|
|
332
|
+
return {
|
|
333
|
+
code: 'deprecated',
|
|
334
|
+
path,
|
|
335
|
+
id,
|
|
336
|
+
message: `${what} is deprecated since ${info.since}${instead}.`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
68
339
|
/**
|
|
69
340
|
* A node's `slot` (audit G04) must name a slot prop of its parent; omitted
|
|
70
341
|
* or `children` is the default slot. A root fills no slot. An unknown
|
|
@@ -87,57 +358,447 @@ function checkSlot(slot, parent, id, path, out) {
|
|
|
87
358
|
: `"${String(slot)}" is not a slot of "${parent.id}".`,
|
|
88
359
|
});
|
|
89
360
|
}
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
361
|
+
function kindProblem(prop, value) {
|
|
362
|
+
const kindOk = prop.kind === 'string' || prop.kind === 'enum'
|
|
363
|
+
? typeof value === 'string'
|
|
364
|
+
: prop.kind === 'number'
|
|
365
|
+
? finite(value)
|
|
366
|
+
: prop.kind === 'boolean'
|
|
367
|
+
? typeof value === 'boolean'
|
|
368
|
+
: prop.kind === 'array'
|
|
369
|
+
? Array.isArray(value)
|
|
370
|
+
: prop.kind === 'object'
|
|
371
|
+
? isRecord(value)
|
|
372
|
+
: false;
|
|
373
|
+
if (!kindOk)
|
|
374
|
+
return 'prop-type';
|
|
375
|
+
if (prop.kind === 'enum' && !(prop.enumValues ?? []).includes(value)) {
|
|
376
|
+
return 'enum-value';
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
/** Whether a value reads as a binding (`$state` or `$data` as an own key). */
|
|
381
|
+
function isBindingShaped(value) {
|
|
382
|
+
return (isRecord(value) &&
|
|
383
|
+
(Object.hasOwn(value, STATE_BINDING_KEY) || Object.hasOwn(value, DATA_BINDING_KEY)));
|
|
384
|
+
}
|
|
385
|
+
/** The binding's key and name when it has exactly one key with a string name. */
|
|
386
|
+
function bindingTarget(value) {
|
|
387
|
+
const keys = Object.keys(value);
|
|
388
|
+
const key = keys[0];
|
|
389
|
+
if (keys.length !== 1 || (key !== STATE_BINDING_KEY && key !== DATA_BINDING_KEY))
|
|
390
|
+
return null;
|
|
391
|
+
const name = value[key];
|
|
392
|
+
return typeof name === 'string' ? { key, name } : null;
|
|
393
|
+
}
|
|
394
|
+
function checkProps(given, bind, component, path, context) {
|
|
395
|
+
const { id, fail } = context;
|
|
93
396
|
const declared = new Map(component.props.map((p) => [p.name, p]));
|
|
94
397
|
for (const [name, value] of Object.entries(given)) {
|
|
95
398
|
const here = `${path}.${name}`;
|
|
96
399
|
const prop = declared.get(name);
|
|
97
400
|
if (!prop) {
|
|
98
|
-
|
|
401
|
+
fail('unknown-prop', here, `"${id}" has no "${name}" prop.`);
|
|
99
402
|
continue;
|
|
100
403
|
}
|
|
101
404
|
if (prop.extension) {
|
|
102
|
-
|
|
103
|
-
code: 'extension-prop',
|
|
104
|
-
path: here,
|
|
105
|
-
id,
|
|
106
|
-
message: `"${name}" is a ${prop.extension} extension (${prop.kind}) and cannot come from data.`,
|
|
107
|
-
});
|
|
405
|
+
fail('extension-prop', here, `"${name}" is a ${prop.extension} extension (${prop.kind}) and cannot come from data.`);
|
|
108
406
|
continue;
|
|
109
407
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
else if (prop.kind === '
|
|
125
|
-
|
|
126
|
-
code: 'enum-value',
|
|
127
|
-
path: here,
|
|
128
|
-
id,
|
|
129
|
-
message: `"${name}" must be one of ${(prop.enumValues ?? []).join(', ')}.`,
|
|
130
|
-
});
|
|
408
|
+
if (isBindingShaped(value)) {
|
|
409
|
+
checkPropBinding(value, prop, here, context);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const problem = kindProblem(prop, value);
|
|
413
|
+
if (problem === 'prop-type') {
|
|
414
|
+
fail('prop-type', here, `"${name}" must be ${prop.kind}.`);
|
|
415
|
+
}
|
|
416
|
+
else if (problem === 'enum-value') {
|
|
417
|
+
fail('enum-value', here, `"${name}" must be one of ${(prop.enumValues ?? []).join(', ')}.`);
|
|
418
|
+
}
|
|
419
|
+
else if (prop.schema && (prop.kind === 'array' || prop.kind === 'object')) {
|
|
420
|
+
checkPropSchemaValue(prop.name, prop.schema, value, here, context);
|
|
421
|
+
}
|
|
422
|
+
else if (prop.kind === 'array' || prop.kind === 'object') {
|
|
423
|
+
walkOpaque(value, here, 0, context);
|
|
131
424
|
}
|
|
132
425
|
}
|
|
133
426
|
for (const prop of component.props) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
path: `${path}.${prop.name}`,
|
|
138
|
-
id,
|
|
139
|
-
message: `"${id}" needs "${prop.name}".`,
|
|
140
|
-
});
|
|
427
|
+
const given_ = Object.hasOwn(given, prop.name) || Object.hasOwn(bind, prop.name);
|
|
428
|
+
if (prop.required && !prop.extension && !given_) {
|
|
429
|
+
fail('missing-required', `${path}.${prop.name}`, `"${id}" needs "${prop.name}".`);
|
|
141
430
|
}
|
|
142
431
|
}
|
|
143
432
|
}
|
|
433
|
+
/** Whether `value` is a legal value of `prop` (kind, enum and property schema). */
|
|
434
|
+
function fits(prop, value, root) {
|
|
435
|
+
if (kindProblem(prop, value) !== null)
|
|
436
|
+
return false;
|
|
437
|
+
if (prop.schema && (prop.kind === 'array' || prop.kind === 'object')) {
|
|
438
|
+
return validateInstance(toDialectSchema(prop.schema), value, root).length === 0;
|
|
439
|
+
}
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
/** A whole prop given as a binding: `$state` of a fitting variable, or `$data` for a scalar prop. */
|
|
443
|
+
function checkPropBinding(value, prop, path, context) {
|
|
444
|
+
const { state } = context;
|
|
445
|
+
const target = bindingTarget(value);
|
|
446
|
+
const fail = (code, message) => context.fail(code, path, message);
|
|
447
|
+
if (!target) {
|
|
448
|
+
fail('binding', BINDING_SHAPE);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (target.key === DATA_BINDING_KEY) {
|
|
452
|
+
if (!SPEC_REGEX.dataName.test(target.name) || target.name.length > 64) {
|
|
453
|
+
fail('binding', `"${target.name}" is not a host data name.`);
|
|
454
|
+
}
|
|
455
|
+
else if (!['string', 'number', 'boolean', 'enum'].includes(prop.kind)) {
|
|
456
|
+
fail('binding', 'Host data binds only a whole string, number, boolean or enum prop.');
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const variable = state.get(target.name);
|
|
461
|
+
if (!variable) {
|
|
462
|
+
fail('unknown-state', `No state variable named "${target.name}".`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (variable.valid && !fits(prop, variable.initial, context.root)) {
|
|
466
|
+
fail('binding', `"${target.name}" (${variable.type}) does not fit "${prop.name}" (${prop.kind}).`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Nested bindings inside an array or object prop without a property schema:
|
|
471
|
+
* `$state` names must be declared; `$data` binds only a whole prop.
|
|
472
|
+
*/
|
|
473
|
+
function walkOpaque(value, path, depth, context) {
|
|
474
|
+
if (depth > SPEC_LIMITS.propDepth)
|
|
475
|
+
return;
|
|
476
|
+
if (Array.isArray(value)) {
|
|
477
|
+
value.forEach((item, index) => {
|
|
478
|
+
walkOpaque(item, `${path}[${index}]`, depth + 1, context);
|
|
479
|
+
});
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (!isRecord(value))
|
|
483
|
+
return;
|
|
484
|
+
if (isBindingShaped(value)) {
|
|
485
|
+
nestedBinding(value, path, context, undefined);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
for (const [key, item] of Object.entries(value))
|
|
489
|
+
walkOpaque(item, `${path}.${key}`, depth + 1, context);
|
|
490
|
+
}
|
|
491
|
+
/** A binding nested in a prop value; `schema` (when known) is the position's schema. */
|
|
492
|
+
function nestedBinding(value, path, context, schema) {
|
|
493
|
+
const { state } = context;
|
|
494
|
+
const target = bindingTarget(value);
|
|
495
|
+
const fail = (code, message) => context.fail(code, path, message);
|
|
496
|
+
if (!target) {
|
|
497
|
+
fail('binding', BINDING_SHAPE);
|
|
498
|
+
}
|
|
499
|
+
else if (target.key === DATA_BINDING_KEY) {
|
|
500
|
+
fail('binding', 'Host data binds only a whole top-level prop.');
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
const variable = state.get(target.name);
|
|
504
|
+
if (!variable)
|
|
505
|
+
fail('unknown-state', `No state variable named "${target.name}".`);
|
|
506
|
+
else if (schema &&
|
|
507
|
+
variable.valid &&
|
|
508
|
+
validateInstance(schema, variable.initial, context.root).length > 0) {
|
|
509
|
+
fail('binding', `"${target.name}" (${variable.type}) does not fit this position.`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* An array or object prop with a property schema: the dialect checks the
|
|
515
|
+
* value (its first issue is the prop's `prop-type`), then binding, reorder,
|
|
516
|
+
* action and workflow positions add their own diagnostics.
|
|
517
|
+
*/
|
|
518
|
+
function checkPropSchemaValue(name, propSchema, value, path, context) {
|
|
519
|
+
const { id, root } = context;
|
|
520
|
+
const defs = root.$defs ?? {};
|
|
521
|
+
const extra = [];
|
|
522
|
+
const sub = reportInto({ ...context, out: extra });
|
|
523
|
+
const at = (relative) => `${path}${relative.slice(1)}`;
|
|
524
|
+
const issues = validateInstance(toDialectSchema(propSchema), value, root, {
|
|
525
|
+
value(node, current, relative) {
|
|
526
|
+
if (node === defs.binding) {
|
|
527
|
+
if (isRecord(current) && Object.keys(current).length !== 1) {
|
|
528
|
+
sub.fail('binding', at(relative), BINDING_SHAPE);
|
|
529
|
+
}
|
|
530
|
+
else if (isRecord(current) &&
|
|
531
|
+
typeof current.$state === 'string' &&
|
|
532
|
+
!context.state.has(current.$state)) {
|
|
533
|
+
sub.fail('unknown-state', at(relative), `No state variable named "${current.$state}".`);
|
|
534
|
+
}
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
if (node === defs.reorder) {
|
|
538
|
+
if (isRecord(current) && isRecord(current.bind)) {
|
|
539
|
+
reorderBinding(current.bind, at(`${relative}.bind`), sub);
|
|
540
|
+
}
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
if (node === defs.action) {
|
|
544
|
+
if (isRecord(current))
|
|
545
|
+
checkAction(current, at(relative), undefined, sub);
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
if (node === defs.workflowGraph) {
|
|
549
|
+
// contractRoot adds workflowGraph only with a validator; a caller-built root fails closed here.
|
|
550
|
+
if (!context.workflow)
|
|
551
|
+
return [{ code: 'bad-ref', path: relative, message: 'No validator.' }];
|
|
552
|
+
const schema = { ...node, $defs: defs };
|
|
553
|
+
const result = context.workflow.validate(current, schema);
|
|
554
|
+
const schemaIssues = [];
|
|
555
|
+
for (const d of result.diagnostics) {
|
|
556
|
+
if (d.code === 'schema')
|
|
557
|
+
schemaIssues.push({
|
|
558
|
+
code: 'schema',
|
|
559
|
+
path: `${relative}${d.path.slice(1)}`,
|
|
560
|
+
message: d.message,
|
|
561
|
+
});
|
|
562
|
+
else
|
|
563
|
+
extra.push({ ...d, path: at(`${relative}${d.path.slice(1)}`), id });
|
|
564
|
+
}
|
|
565
|
+
return schemaIssues;
|
|
566
|
+
}
|
|
567
|
+
if (isBindingShaped(current)) {
|
|
568
|
+
nestedBinding(current, at(relative), sub, node);
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
return undefined;
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
const first = issues[0];
|
|
575
|
+
if (first) {
|
|
576
|
+
context.fail('prop-type', at(first.path), `"${name}" does not match its schema: ${first.code}: ${first.message}`);
|
|
577
|
+
}
|
|
578
|
+
context.out.push(...extra);
|
|
579
|
+
}
|
|
580
|
+
/** `reorder.bind` must be `$state` of a writable list. */
|
|
581
|
+
function reorderBinding(bind, path, context) {
|
|
582
|
+
const name = bind.$state;
|
|
583
|
+
if (typeof name !== 'string' || Object.keys(bind).length !== 1) {
|
|
584
|
+
if (typeof bind.$data === 'string') {
|
|
585
|
+
context.fail('binding', path, 'reorder needs a writable list, not host data.');
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const variable = context.state.get(name);
|
|
590
|
+
if (variable?.valid && (variable.type !== 'list' || !variable.writable)) {
|
|
591
|
+
context.fail('binding', path, `reorder binds a writable list; "${name}" is not one.`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/** `bind`: two-way bindings to bindable props, `$state` only, writable and fitting. */
|
|
595
|
+
function checkBind(bind, props, component, path, context) {
|
|
596
|
+
const { id, state } = context;
|
|
597
|
+
for (const [name, value] of Object.entries(bind)) {
|
|
598
|
+
const here = `${path}.${name}`;
|
|
599
|
+
const fail = (code, message) => context.fail(code, here, message);
|
|
600
|
+
const prop = component.props.find((p) => p.name === name);
|
|
601
|
+
if (!prop) {
|
|
602
|
+
fail('unknown-prop', `"${id}" has no "${name}" prop.`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (!(component.bindable ?? []).includes(name)) {
|
|
606
|
+
fail('binding', `"${name}" is not bindable on "${id}".`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (Object.hasOwn(props, name)) {
|
|
610
|
+
fail('binding', `"${name}" is set both as a prop and as a binding.`);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const target = isRecord(value) ? bindingTarget(value) : null;
|
|
614
|
+
if (!target || target.key !== STATE_BINDING_KEY) {
|
|
615
|
+
fail('binding', 'bind takes exactly {"$state": name}; host data is read-only.');
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const variable = state.get(target.name);
|
|
619
|
+
if (!variable) {
|
|
620
|
+
fail('unknown-state', `No state variable named "${target.name}".`);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (!variable.valid)
|
|
624
|
+
continue;
|
|
625
|
+
if (!variable.writable) {
|
|
626
|
+
fail('binding', `"${target.name}" is host-bound and read-only.`);
|
|
627
|
+
}
|
|
628
|
+
else if (!fits(prop, variable.initial, context.root)) {
|
|
629
|
+
fail('binding', `"${target.name}" (${variable.type}) does not fit "${name}" (${prop.kind}).`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** `on`: events the component declares, each with its closed actions. */
|
|
634
|
+
function checkOn(on, component, path, context) {
|
|
635
|
+
for (const [event, actions] of Object.entries(on)) {
|
|
636
|
+
const here = `${path}.${event}`;
|
|
637
|
+
if (!(component.events ?? []).includes(event)) {
|
|
638
|
+
context.fail('unknown-event', here, `"${component.id}" fires no "${event}" event.`);
|
|
639
|
+
}
|
|
640
|
+
if (!Array.isArray(actions))
|
|
641
|
+
continue;
|
|
642
|
+
actions.forEach((action, index) => {
|
|
643
|
+
if (isRecord(action))
|
|
644
|
+
checkAction(action, `${here}[${index}]`, event, context);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* One closed action: the fields its op takes, then its targets. At most one
|
|
650
|
+
* diagnostic per action (the first problem). `event` is the firing event,
|
|
651
|
+
* unknown for an action given as a prop value.
|
|
652
|
+
*/
|
|
653
|
+
function checkAction(action, path, event, context) {
|
|
654
|
+
const { state, flow, fail } = context;
|
|
655
|
+
const op = action.op;
|
|
656
|
+
if (!SPEC_ACTION_OPS.includes(op))
|
|
657
|
+
return;
|
|
658
|
+
const fields = ACTION_FIELDS[op];
|
|
659
|
+
// Unknown keys are the schema's to report; only fields of another op are judged here.
|
|
660
|
+
const extra = Object.keys(action).find((key) => ACTION_KEYS.has(key) && !fields.allowed.includes(key));
|
|
661
|
+
if (extra !== undefined) {
|
|
662
|
+
fail('action', `${path}.${extra}`, `${String(op)} takes no "${extra}".`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const missing = fields.required.find((key) => !Object.hasOwn(action, key));
|
|
666
|
+
if (missing !== undefined) {
|
|
667
|
+
fail('action', `${path}.${missing}`, `${String(op)} needs "${missing}".`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
/** The declared variable `key` names, or null after reporting it. */
|
|
671
|
+
const variable = (key) => {
|
|
672
|
+
const name = action[key];
|
|
673
|
+
if (typeof name !== 'string')
|
|
674
|
+
return null;
|
|
675
|
+
const found = state.get(name);
|
|
676
|
+
if (!found) {
|
|
677
|
+
fail('unknown-state', `${path}.${key}`, `No state variable named "${name}".`);
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
// A refused declaration was reported once; judging its uses would only cascade.
|
|
681
|
+
if (!found.valid)
|
|
682
|
+
return null;
|
|
683
|
+
if (!found.writable) {
|
|
684
|
+
fail('action', `${path}.${key}`, `"${name}" is host-bound and read-only.`);
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
return found;
|
|
688
|
+
};
|
|
689
|
+
switch (op) {
|
|
690
|
+
case 'setState': {
|
|
691
|
+
const hasValue = Object.hasOwn(action, 'value');
|
|
692
|
+
if (hasValue === Object.hasOwn(action, 'fromEvent')) {
|
|
693
|
+
fail('action', path, 'setState takes exactly one of "value" and "fromEvent".');
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const target = variable('state');
|
|
697
|
+
if (!target)
|
|
698
|
+
return;
|
|
699
|
+
if (target.type === 'list' || target.type === 'workflow') {
|
|
700
|
+
fail('action', `${path}.state`, `setState cannot replace a ${target.type}; use moveItem.`);
|
|
701
|
+
}
|
|
702
|
+
else if (hasValue && target.valid && !fitsVariable(target, action.value)) {
|
|
703
|
+
fail('action', `${path}.value`, `The value does not fit "${String(action.state)}".`);
|
|
704
|
+
}
|
|
705
|
+
else if (!hasValue && event !== undefined && event !== 'change' && event !== 'select') {
|
|
706
|
+
fail('action', `${path}.fromEvent`, 'fromEvent reads the value of a change or select event.');
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
case 'toggleState': {
|
|
711
|
+
const target = variable('state');
|
|
712
|
+
if (target && target.type !== 'boolean') {
|
|
713
|
+
fail('action', `${path}.state`, 'toggleState flips a boolean.');
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
case 'moveItem': {
|
|
718
|
+
for (const key of ['from', 'to']) {
|
|
719
|
+
if (!Object.hasOwn(action, key))
|
|
720
|
+
continue;
|
|
721
|
+
const target = variable(key);
|
|
722
|
+
if (!target)
|
|
723
|
+
return;
|
|
724
|
+
if (target.type !== 'list') {
|
|
725
|
+
fail('action', `${path}.${key}`, 'moveItem moves items between lists.');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
case 'flowGo': {
|
|
732
|
+
if (!flow)
|
|
733
|
+
fail('action', path, 'flowGo needs a declared flow.');
|
|
734
|
+
else if (!flow.events.has(action.event)) {
|
|
735
|
+
fail('action', `${path}.event`, `No transition handles "${String(action.event)}".`);
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
case 'emit': {
|
|
740
|
+
if (!isRecord(action.values))
|
|
741
|
+
return;
|
|
742
|
+
const entries = Object.entries(action.values);
|
|
743
|
+
if (entries.length > SPEC_LIMITS.emitValues) {
|
|
744
|
+
fail('action', `${path}.values`, `At most ${SPEC_LIMITS.emitValues} values.`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
for (const [name, value] of entries) {
|
|
748
|
+
const here = `${path}.values.${name}`;
|
|
749
|
+
if (!SPEC_REGEX.intentName.test(name)) {
|
|
750
|
+
fail('action', here, `"${name}" is not a value name.`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (isRecord(value)) {
|
|
754
|
+
const target = bindingTarget(value);
|
|
755
|
+
if (!target || target.key !== STATE_BINDING_KEY) {
|
|
756
|
+
fail('action', here, 'An emitted value is a scalar or {"$state": name}.');
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const found = state.get(target.name);
|
|
760
|
+
if (!found) {
|
|
761
|
+
fail('unknown-state', here, `No state variable named "${target.name}".`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (found.valid && (found.type === 'list' || found.type === 'workflow')) {
|
|
765
|
+
fail('action', here, 'Only scalar variables can be emitted.');
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
else if (!(typeof value === 'boolean' ||
|
|
770
|
+
finite(value) ||
|
|
771
|
+
(typeof value === 'string' && value.length <= SPEC_LIMITS.emitValueLength))) {
|
|
772
|
+
fail('action', here, 'An emitted value is a boolean, a number or a short string.');
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
case 'submit': {
|
|
779
|
+
if (!context.insideForm)
|
|
780
|
+
fail('action', path, `submit runs only inside an ${FORM_ID}.`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/** Whether `value` is a legal value of a scalar variable. */
|
|
786
|
+
function fitsVariable(variable, value) {
|
|
787
|
+
switch (variable.type) {
|
|
788
|
+
case 'boolean':
|
|
789
|
+
return typeof value === 'boolean';
|
|
790
|
+
case 'number':
|
|
791
|
+
return (finite(value) &&
|
|
792
|
+
value >= (variable.min ?? Number.NEGATIVE_INFINITY) &&
|
|
793
|
+
value <= (variable.max ?? Number.POSITIVE_INFINITY));
|
|
794
|
+
case 'enum':
|
|
795
|
+
return typeof value === 'string' && (variable.values ?? []).includes(value);
|
|
796
|
+
case 'string':
|
|
797
|
+
return typeof value === 'string' && value.length <= (variable.maxLength ?? 0);
|
|
798
|
+
default:
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function finite(value) {
|
|
803
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
804
|
+
}
|