@smallpen/core 0.1.0-alpha.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 +18 -0
- package/src/canonical.mjs +64 -0
- package/src/capabilities.mjs +495 -0
- package/src/catalog.mjs +362 -0
- package/src/component-samples.mjs +84 -0
- package/src/components-domain.mjs +335 -0
- package/src/contexts.mjs +248 -0
- package/src/design-projection.mjs +657 -0
- package/src/design-read.mjs +825 -0
- package/src/design-system-authoring.mjs +102 -0
- package/src/design-system-canvas.mjs +862 -0
- package/src/design-system.mjs +437 -0
- package/src/design-validation.mjs +324 -0
- package/src/draft.mjs +784 -0
- package/src/effective-tokens.mjs +377 -0
- package/src/errors.mjs +12 -0
- package/src/index.mjs +112 -0
- package/src/initialization.mjs +310 -0
- package/src/package.mjs +5935 -0
- package/src/projection-values.mjs +138 -0
- package/src/requirements-domain.mjs +222 -0
- package/src/scenarios-domain.mjs +268 -0
- package/src/token-advice.mjs +272 -0
- package/src/token-import.mjs +607 -0
- package/src/tokens-domain.mjs +410 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { resolveContext } from "./contexts.mjs";
|
|
2
|
+
import { resolveEffectiveToken } from "./effective-tokens.mjs";
|
|
3
|
+
import { fail } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
const FIELD_TYPES = new Map([
|
|
6
|
+
["backgroundBlur", ["other", "number"]],
|
|
7
|
+
["blur", ["other", "number"]],
|
|
8
|
+
["cornerRadius", ["border-radius", "dimension", "dimensions", "number"]],
|
|
9
|
+
["fill", ["color"]],
|
|
10
|
+
["fontFamily", ["font-family", "string"]],
|
|
11
|
+
["fontSize", ["dimension", "dimensions", "font-size", "number"]],
|
|
12
|
+
["fontWeight", ["font-weight", "number"]],
|
|
13
|
+
["height", ["dimension", "dimensions", "number", "sizing"]],
|
|
14
|
+
["itemSpacing", ["dimension", "dimensions", "number", "spacing"]],
|
|
15
|
+
["opacity", ["number", "opacity"]],
|
|
16
|
+
["paddingBottom", ["dimension", "dimensions", "number", "spacing"]],
|
|
17
|
+
["paddingLeft", ["dimension", "dimensions", "number", "spacing"]],
|
|
18
|
+
["paddingRight", ["dimension", "dimensions", "number", "spacing"]],
|
|
19
|
+
["paddingTop", ["dimension", "dimensions", "number", "spacing"]],
|
|
20
|
+
["shadow", ["shadow"]],
|
|
21
|
+
["strokeWidth", ["stroke-width", "dimension", "dimensions", "number"]],
|
|
22
|
+
["typography", ["typography"]],
|
|
23
|
+
["width", ["dimension", "dimensions", "number", "sizing"]],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function compatible(field, token) {
|
|
27
|
+
const baseField = field.startsWith("fills.") ? "fill" : field;
|
|
28
|
+
return FIELD_TYPES.get(baseField)?.includes(token.type) === true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assign(node, field, value) {
|
|
32
|
+
if (field === "fill") {
|
|
33
|
+
node.fills = [{ color: value, type: "solid" }];
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (field === "strokeWidth") {
|
|
37
|
+
if (!Array.isArray(node.strokes) || node.strokes.length === 0) {
|
|
38
|
+
fail(
|
|
39
|
+
"missing_token_binding_target",
|
|
40
|
+
`Token binding target is missing: ${field}`,
|
|
41
|
+
{ field, nodeId: node.id },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
node.strokes = node.strokes.map((stroke) => ({
|
|
45
|
+
...stroke,
|
|
46
|
+
width: value,
|
|
47
|
+
}));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const fillMatch = /^fills\.(\d+)$/.exec(field);
|
|
51
|
+
if (fillMatch) {
|
|
52
|
+
const index = Number(fillMatch[1]);
|
|
53
|
+
const fills = structuredClone(node.fills ?? []);
|
|
54
|
+
if (!fills[index]) {
|
|
55
|
+
fail("missing_token_binding_target", `Token binding target is missing: ${field}`, {
|
|
56
|
+
field,
|
|
57
|
+
nodeId: node.id,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
fills[index] = { ...fills[index], color: value, type: "solid" };
|
|
61
|
+
node.fills = fills;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (field === "typography") {
|
|
65
|
+
node.textStyle = { ...(node.textStyle ?? {}), ...structuredClone(value) };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (["fontFamily", "fontSize", "fontWeight"].includes(field)) {
|
|
69
|
+
node.textStyle = { ...(node.textStyle ?? {}), [field]: value };
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
node[field] = structuredClone(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function applyEffectiveTokenBindings(nodeValue, product, options = {}) {
|
|
76
|
+
const node = structuredClone(nodeValue);
|
|
77
|
+
for (const [field, reference] of Object.entries(node.tokenBindings ?? {})) {
|
|
78
|
+
const effective = resolveEffectiveToken(product, reference, options);
|
|
79
|
+
if (!effective) {
|
|
80
|
+
fail("missing_token", `Token not found: ${reference.assetId}`, {
|
|
81
|
+
field,
|
|
82
|
+
nodeId: node.id,
|
|
83
|
+
path: `${node.id}.tokenBindings.${field}`,
|
|
84
|
+
reference,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (!compatible(field, effective.token)) {
|
|
88
|
+
fail(
|
|
89
|
+
"binding_type_mismatch",
|
|
90
|
+
`Token type ${effective.token.type} cannot bind to ${field}`,
|
|
91
|
+
{
|
|
92
|
+
field,
|
|
93
|
+
nodeId: node.id,
|
|
94
|
+
path: `${node.id}.tokenBindings.${field}`,
|
|
95
|
+
tokenId: effective.token.id,
|
|
96
|
+
tokenType: effective.token.type,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
assign(node, field, effective.value);
|
|
101
|
+
}
|
|
102
|
+
return node;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function projectEffectiveSnapshot(product, options = {}) {
|
|
106
|
+
const foundation = options.foundation;
|
|
107
|
+
const libraries = options.libraries ?? [];
|
|
108
|
+
const context = resolveContext(product, foundation, options.context ?? {});
|
|
109
|
+
const entries = structuredClone(product.entries);
|
|
110
|
+
for (const entry of product.manifest.entries.screens) {
|
|
111
|
+
for (const presentation of entries[entry].presentations) {
|
|
112
|
+
for (const [nodeId, node] of Object.entries(presentation.nodes)) {
|
|
113
|
+
presentation.nodes[nodeId] = applyEffectiveTokenBindings(node, product, {
|
|
114
|
+
context,
|
|
115
|
+
foundation,
|
|
116
|
+
libraries,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const entry of product.manifest.entries.components) {
|
|
122
|
+
for (const set of entries[entry].componentSets ?? []) {
|
|
123
|
+
for (const variant of set.variants ?? []) {
|
|
124
|
+
for (const [nodeId, node] of Object.entries(variant.nodes)) {
|
|
125
|
+
variant.nodes[nodeId] = applyEffectiveTokenBindings(node, product, {
|
|
126
|
+
context, foundation, libraries,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
...product,
|
|
134
|
+
domain: product.domain,
|
|
135
|
+
entries,
|
|
136
|
+
projection: { context },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { fail } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function stableId(value, prefix, code, path) {
|
|
8
|
+
if (
|
|
9
|
+
typeof value !== "string" ||
|
|
10
|
+
!value.startsWith(prefix) ||
|
|
11
|
+
!/^[a-zA-Z0-9_-]+$/.test(value)
|
|
12
|
+
) {
|
|
13
|
+
fail(code, `${path} must begin with ${prefix}`, { path, value });
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nonEmpty(value, code, path) {
|
|
19
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
20
|
+
fail(code, `${path} must be a non-empty string`, { path, value });
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assetReference(value, prefix, path) {
|
|
26
|
+
if (
|
|
27
|
+
!isRecord(value) ||
|
|
28
|
+
Object.keys(value).length !== 2 ||
|
|
29
|
+
typeof value.packageId !== "string" ||
|
|
30
|
+
!value.packageId.startsWith("pkg_") ||
|
|
31
|
+
typeof value.assetId !== "string" ||
|
|
32
|
+
!value.assetId.startsWith(prefix)
|
|
33
|
+
) {
|
|
34
|
+
fail("invalid_asset_reference", `${path} is invalid`, { path });
|
|
35
|
+
}
|
|
36
|
+
return structuredClone(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseDesignTarget(value, path) {
|
|
40
|
+
if (!isRecord(value) || typeof value.kind !== "string") {
|
|
41
|
+
fail("invalid_design_target", `${path} is invalid`, { path });
|
|
42
|
+
}
|
|
43
|
+
if (value.kind === "node") {
|
|
44
|
+
if (
|
|
45
|
+
Object.keys(value).length !== 4 ||
|
|
46
|
+
typeof value.presentationId !== "string" ||
|
|
47
|
+
!value.presentationId.startsWith("pres_") ||
|
|
48
|
+
typeof value.nodeId !== "string" ||
|
|
49
|
+
!value.nodeId.startsWith("node_")
|
|
50
|
+
) {
|
|
51
|
+
fail("invalid_design_target", `${path} Node target is invalid`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
kind: "node",
|
|
55
|
+
nodeId: value.nodeId,
|
|
56
|
+
presentationId: value.presentationId,
|
|
57
|
+
screen: assetReference(value.screen, "scr_", `${path}.screen`),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (value.kind === "screen") {
|
|
61
|
+
if (Object.keys(value).length !== 2) {
|
|
62
|
+
fail("invalid_design_target", `${path} Screen target is invalid`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
kind: "screen",
|
|
66
|
+
screen: assetReference(value.screen, "scr_", `${path}.screen`),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const idFields = {
|
|
70
|
+
flow: ["flowId", "flow_"],
|
|
71
|
+
interaction: ["interactionId", "int_"],
|
|
72
|
+
scenario: ["scenarioId", "scn_"],
|
|
73
|
+
};
|
|
74
|
+
const descriptor = idFields[value.kind];
|
|
75
|
+
if (!descriptor || Object.keys(value).length !== 2) {
|
|
76
|
+
fail("invalid_design_target", `${path}.kind is unsupported`, {
|
|
77
|
+
kind: value.kind,
|
|
78
|
+
path,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const [field, prefix] = descriptor;
|
|
82
|
+
stableId(value[field], prefix, "invalid_design_target", `${path}.${field}`);
|
|
83
|
+
return structuredClone(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseRequirement(value, path) {
|
|
87
|
+
if (
|
|
88
|
+
!isRecord(value) ||
|
|
89
|
+
Object.keys(value).length !== 4 ||
|
|
90
|
+
!Array.isArray(value.links) ||
|
|
91
|
+
typeof value.markdown !== "string"
|
|
92
|
+
) {
|
|
93
|
+
fail("invalid_requirement", `${path} is invalid`);
|
|
94
|
+
}
|
|
95
|
+
stableId(value.id, "req_", "invalid_requirement_id", `${path}.id`);
|
|
96
|
+
nonEmpty(value.title, "invalid_requirement_title", `${path}.title`);
|
|
97
|
+
return {
|
|
98
|
+
id: value.id,
|
|
99
|
+
links: value.links.map((link, index) =>
|
|
100
|
+
parseDesignTarget(link, `${path}.links[${index}]`),
|
|
101
|
+
),
|
|
102
|
+
markdown: value.markdown,
|
|
103
|
+
title: value.title,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseFlow(value, path) {
|
|
108
|
+
if (
|
|
109
|
+
!isRecord(value) ||
|
|
110
|
+
Object.keys(value).length !== 3 ||
|
|
111
|
+
!Array.isArray(value.interactionIds) ||
|
|
112
|
+
value.interactionIds.some(
|
|
113
|
+
(id) => typeof id !== "string" || !id.startsWith("int_"),
|
|
114
|
+
) ||
|
|
115
|
+
new Set(value.interactionIds).size !== value.interactionIds.length
|
|
116
|
+
) {
|
|
117
|
+
fail("invalid_flow", `${path} is invalid`);
|
|
118
|
+
}
|
|
119
|
+
stableId(value.id, "flow_", "invalid_flow_id", `${path}.id`);
|
|
120
|
+
nonEmpty(value.name, "invalid_flow_name", `${path}.name`);
|
|
121
|
+
return {
|
|
122
|
+
id: value.id,
|
|
123
|
+
interactionIds: [...value.interactionIds],
|
|
124
|
+
name: value.name,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseAnnotation(value, path) {
|
|
129
|
+
if (!isRecord(value) || !Array.isArray(value.tags)) {
|
|
130
|
+
fail("invalid_semantic_annotation", `${path} is invalid`);
|
|
131
|
+
}
|
|
132
|
+
const fields = new Set(["description", "id", "role", "tags", "target"]);
|
|
133
|
+
if (Object.keys(value).some((field) => !fields.has(field))) {
|
|
134
|
+
fail("invalid_semantic_annotation", `${path} fields are invalid`);
|
|
135
|
+
}
|
|
136
|
+
stableId(value.id, "ann_", "invalid_annotation_id", `${path}.id`);
|
|
137
|
+
if (
|
|
138
|
+
value.description !== undefined &&
|
|
139
|
+
typeof value.description !== "string"
|
|
140
|
+
) {
|
|
141
|
+
fail(
|
|
142
|
+
"invalid_annotation_description",
|
|
143
|
+
`${path}.description must be a string`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
value.role !== undefined &&
|
|
148
|
+
(typeof value.role !== "string" || value.role.length === 0)
|
|
149
|
+
) {
|
|
150
|
+
fail("invalid_annotation_role", `${path}.role must be non-empty`);
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
value.tags.some((tag) => typeof tag !== "string" || tag.length === 0) ||
|
|
154
|
+
new Set(value.tags).size !== value.tags.length
|
|
155
|
+
) {
|
|
156
|
+
fail("invalid_annotation_tags", `${path}.tags must contain unique strings`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
...(value.description === undefined
|
|
160
|
+
? {}
|
|
161
|
+
: { description: value.description }),
|
|
162
|
+
id: value.id,
|
|
163
|
+
...(value.role === undefined ? {} : { role: value.role }),
|
|
164
|
+
tags: [...value.tags],
|
|
165
|
+
target: parseDesignTarget(value.target, `${path}.target`),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseRequirementEntries(manifest, entries) {
|
|
170
|
+
const annotations = new Map();
|
|
171
|
+
const flows = new Map();
|
|
172
|
+
const requirements = new Map();
|
|
173
|
+
for (const entry of manifest.entries.requirements) {
|
|
174
|
+
const value = entries[entry];
|
|
175
|
+
if (
|
|
176
|
+
!isRecord(value) ||
|
|
177
|
+
Object.keys(value).length !== 3 ||
|
|
178
|
+
!Array.isArray(value.annotations) ||
|
|
179
|
+
!Array.isArray(value.flows) ||
|
|
180
|
+
!Array.isArray(value.requirements)
|
|
181
|
+
) {
|
|
182
|
+
fail(
|
|
183
|
+
"invalid_requirement_file",
|
|
184
|
+
`${entry} requires annotations, flows, and requirements arrays`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
for (const [index, candidate] of value.annotations.entries()) {
|
|
188
|
+
const annotation = parseAnnotation(
|
|
189
|
+
candidate,
|
|
190
|
+
`${entry}.annotations[${index}]`,
|
|
191
|
+
);
|
|
192
|
+
if (annotations.has(annotation.id)) {
|
|
193
|
+
fail(
|
|
194
|
+
"duplicate_annotation_id",
|
|
195
|
+
`Duplicate Annotation id: ${annotation.id}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
annotations.set(annotation.id, annotation);
|
|
199
|
+
}
|
|
200
|
+
for (const [index, candidate] of value.flows.entries()) {
|
|
201
|
+
const flow = parseFlow(candidate, `${entry}.flows[${index}]`);
|
|
202
|
+
if (flows.has(flow.id)) {
|
|
203
|
+
fail("duplicate_flow_id", `Duplicate Flow id: ${flow.id}`);
|
|
204
|
+
}
|
|
205
|
+
flows.set(flow.id, flow);
|
|
206
|
+
}
|
|
207
|
+
for (const [index, candidate] of value.requirements.entries()) {
|
|
208
|
+
const requirement = parseRequirement(
|
|
209
|
+
candidate,
|
|
210
|
+
`${entry}.requirements[${index}]`,
|
|
211
|
+
);
|
|
212
|
+
if (requirements.has(requirement.id)) {
|
|
213
|
+
fail(
|
|
214
|
+
"duplicate_requirement_id",
|
|
215
|
+
`Duplicate Requirement id: ${requirement.id}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
requirements.set(requirement.id, requirement);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { annotations, flows, requirements };
|
|
222
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { fail } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function stableId(value, prefix, code, path) {
|
|
8
|
+
if (
|
|
9
|
+
typeof value !== "string" ||
|
|
10
|
+
!value.startsWith(prefix) ||
|
|
11
|
+
!/^[a-zA-Z0-9_-]+$/.test(value)
|
|
12
|
+
) {
|
|
13
|
+
fail(code, `${path} must begin with ${prefix}`, { path, value });
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nonEmpty(value, code, path) {
|
|
19
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
20
|
+
fail(code, `${path} must be a non-empty string`, { path, value });
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stringRecord(value, code, path) {
|
|
26
|
+
if (!isRecord(value)) fail(code, `${path} must contain an object`, { path });
|
|
27
|
+
for (const [field, child] of Object.entries(value)) {
|
|
28
|
+
if (field.length === 0 || typeof child !== "string" || child.length === 0) {
|
|
29
|
+
fail(code, `${path} must map strings to strings`, { path });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return structuredClone(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assetReference(value, prefix, path) {
|
|
36
|
+
if (
|
|
37
|
+
!isRecord(value) ||
|
|
38
|
+
Object.keys(value).length !== 2 ||
|
|
39
|
+
typeof value.packageId !== "string" ||
|
|
40
|
+
!value.packageId.startsWith("pkg_") ||
|
|
41
|
+
typeof value.assetId !== "string" ||
|
|
42
|
+
!value.assetId.startsWith(prefix)
|
|
43
|
+
) {
|
|
44
|
+
fail("invalid_asset_reference", `${path} is invalid`, { path });
|
|
45
|
+
}
|
|
46
|
+
return structuredClone(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseTarget(value, path) {
|
|
50
|
+
if (!isRecord(value)) fail("invalid_scenario_target", `${path} is invalid`);
|
|
51
|
+
if (value.kind === "screen") {
|
|
52
|
+
if (
|
|
53
|
+
Object.keys(value).length !== 3 ||
|
|
54
|
+
typeof value.presentationId !== "string" ||
|
|
55
|
+
!value.presentationId.startsWith("pres_")
|
|
56
|
+
) {
|
|
57
|
+
fail("invalid_screen_reference", `${path} Screen target is invalid`);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
kind: "screen",
|
|
61
|
+
presentationId: value.presentationId,
|
|
62
|
+
screen: assetReference(value.screen, "scr_", `${path}.screen`),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (value.kind === "component") {
|
|
66
|
+
if (Object.keys(value).length !== 3) {
|
|
67
|
+
fail("invalid_component_target", `${path} Component target is invalid`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
component: assetReference(
|
|
71
|
+
value.component,
|
|
72
|
+
"cmp_",
|
|
73
|
+
`${path}.component`,
|
|
74
|
+
),
|
|
75
|
+
kind: "component",
|
|
76
|
+
variant: stringRecord(
|
|
77
|
+
value.variant,
|
|
78
|
+
"invalid_component_variant_selection",
|
|
79
|
+
`${path}.variant`,
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
fail(
|
|
84
|
+
"invalid_scenario_target_kind",
|
|
85
|
+
`${path}.kind must be screen or component`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseOverrideValue(value, path) {
|
|
90
|
+
if (
|
|
91
|
+
typeof value === "boolean" ||
|
|
92
|
+
typeof value === "number" ||
|
|
93
|
+
typeof value === "string"
|
|
94
|
+
) {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
Array.isArray(value) &&
|
|
99
|
+
value.every(
|
|
100
|
+
(paint) =>
|
|
101
|
+
isRecord(paint) &&
|
|
102
|
+
paint.type === "solid" &&
|
|
103
|
+
typeof paint.color === "string" &&
|
|
104
|
+
(paint.opacity === undefined ||
|
|
105
|
+
(typeof paint.opacity === "number" &&
|
|
106
|
+
Number.isFinite(paint.opacity) &&
|
|
107
|
+
paint.opacity >= 0 &&
|
|
108
|
+
paint.opacity <= 1)),
|
|
109
|
+
)
|
|
110
|
+
) {
|
|
111
|
+
return structuredClone(value);
|
|
112
|
+
}
|
|
113
|
+
fail("invalid_scenario_override", `${path} is unsupported`, { path });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseAction(value, path) {
|
|
117
|
+
if (!isRecord(value) || typeof value.type !== "string") {
|
|
118
|
+
fail("invalid_scenario_action", `${path} must contain an object`);
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
value.type === "set-state" &&
|
|
122
|
+
Object.keys(value).length === 3 &&
|
|
123
|
+
typeof value.axisId === "string" &&
|
|
124
|
+
value.axisId.startsWith("axis_") &&
|
|
125
|
+
typeof value.value === "string" &&
|
|
126
|
+
value.value.length > 0
|
|
127
|
+
) {
|
|
128
|
+
return structuredClone(value);
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
value.type === "set-text" &&
|
|
132
|
+
Object.keys(value).length === 3 &&
|
|
133
|
+
typeof value.nodeId === "string" &&
|
|
134
|
+
value.nodeId.startsWith("node_") &&
|
|
135
|
+
typeof value.value === "string"
|
|
136
|
+
) {
|
|
137
|
+
return structuredClone(value);
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
value.type === "set-visibility" &&
|
|
141
|
+
Object.keys(value).length === 3 &&
|
|
142
|
+
typeof value.nodeId === "string" &&
|
|
143
|
+
value.nodeId.startsWith("node_") &&
|
|
144
|
+
typeof value.visible === "boolean"
|
|
145
|
+
) {
|
|
146
|
+
return structuredClone(value);
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
value.type === "set-override" &&
|
|
150
|
+
Object.keys(value).length === 3 &&
|
|
151
|
+
typeof value.overridePath === "string" &&
|
|
152
|
+
value.overridePath.length > 0
|
|
153
|
+
) {
|
|
154
|
+
return {
|
|
155
|
+
overridePath: value.overridePath,
|
|
156
|
+
type: "set-override",
|
|
157
|
+
value: parseOverrideValue(value.value, `${path}.value`),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
fail(
|
|
161
|
+
"unsupported_scenario_action",
|
|
162
|
+
`${path} must use a finite declarative Scenario action`,
|
|
163
|
+
{ path, type: value.type },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseScenario(value, path) {
|
|
168
|
+
if (!isRecord(value)) fail("invalid_scenario", `${path} is invalid`);
|
|
169
|
+
const fields = new Set([
|
|
170
|
+
"actions",
|
|
171
|
+
"context",
|
|
172
|
+
"expectedVisibleNodeIds",
|
|
173
|
+
"fixture",
|
|
174
|
+
"id",
|
|
175
|
+
"name",
|
|
176
|
+
"target",
|
|
177
|
+
"viewport",
|
|
178
|
+
]);
|
|
179
|
+
if (
|
|
180
|
+
Object.keys(value).length !== fields.size ||
|
|
181
|
+
Object.keys(value).some((field) => !fields.has(field))
|
|
182
|
+
) {
|
|
183
|
+
fail("invalid_scenario", `${path} fields are invalid`);
|
|
184
|
+
}
|
|
185
|
+
stableId(value.id, "scn_", "invalid_scenario_id", `${path}.id`);
|
|
186
|
+
nonEmpty(value.name, "invalid_scenario_name", `${path}.name`);
|
|
187
|
+
if (!Array.isArray(value.actions)) {
|
|
188
|
+
fail("invalid_scenario_actions", `${path}.actions must be an array`);
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
!Array.isArray(value.expectedVisibleNodeIds) ||
|
|
192
|
+
value.expectedVisibleNodeIds.some(
|
|
193
|
+
(nodeId) => typeof nodeId !== "string" || !nodeId.startsWith("node_"),
|
|
194
|
+
) ||
|
|
195
|
+
new Set(value.expectedVisibleNodeIds).size !==
|
|
196
|
+
value.expectedVisibleNodeIds.length
|
|
197
|
+
) {
|
|
198
|
+
fail(
|
|
199
|
+
"invalid_visibility_expectation",
|
|
200
|
+
`${path}.expectedVisibleNodeIds must contain unique Node ids`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
!isRecord(value.fixture) ||
|
|
205
|
+
Object.values(value.fixture).some(
|
|
206
|
+
(entry) =>
|
|
207
|
+
entry !== null &&
|
|
208
|
+
typeof entry !== "boolean" &&
|
|
209
|
+
typeof entry !== "string" &&
|
|
210
|
+
(typeof entry !== "number" || !Number.isFinite(entry)),
|
|
211
|
+
)
|
|
212
|
+
) {
|
|
213
|
+
fail("invalid_fixture", `${path}.fixture must contain scalar values`);
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
!isRecord(value.viewport) ||
|
|
217
|
+
!["height", "scale", "width"].every(
|
|
218
|
+
(field) =>
|
|
219
|
+
typeof value.viewport[field] === "number" &&
|
|
220
|
+
Number.isFinite(value.viewport[field]) &&
|
|
221
|
+
value.viewport[field] > 0,
|
|
222
|
+
) ||
|
|
223
|
+
Object.keys(value.viewport).length !== 3
|
|
224
|
+
) {
|
|
225
|
+
fail("invalid_viewport", `${path}.viewport values must be positive`);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
actions: value.actions.map((action, index) =>
|
|
229
|
+
parseAction(action, `${path}.actions[${index}]`),
|
|
230
|
+
),
|
|
231
|
+
context: stringRecord(
|
|
232
|
+
value.context,
|
|
233
|
+
"invalid_scenario_context",
|
|
234
|
+
`${path}.context`,
|
|
235
|
+
),
|
|
236
|
+
expectedVisibleNodeIds: [...value.expectedVisibleNodeIds],
|
|
237
|
+
fixture: structuredClone(value.fixture),
|
|
238
|
+
id: value.id,
|
|
239
|
+
name: value.name,
|
|
240
|
+
target: parseTarget(value.target, `${path}.target`),
|
|
241
|
+
viewport: structuredClone(value.viewport),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function parseScenarioEntries(manifest, entries) {
|
|
246
|
+
const scenarios = new Map();
|
|
247
|
+
for (const entry of manifest.entries.scenarios) {
|
|
248
|
+
const value = entries[entry];
|
|
249
|
+
if (
|
|
250
|
+
!isRecord(value) ||
|
|
251
|
+
Object.keys(value).length !== 1 ||
|
|
252
|
+
!Array.isArray(value.scenarios)
|
|
253
|
+
) {
|
|
254
|
+
fail(
|
|
255
|
+
"invalid_scenario_file",
|
|
256
|
+
`${entry} must contain a scenarios array`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
for (const [index, candidate] of value.scenarios.entries()) {
|
|
260
|
+
const scenario = parseScenario(candidate, `${entry}.scenarios[${index}]`);
|
|
261
|
+
if (scenarios.has(scenario.id)) {
|
|
262
|
+
fail("duplicate_scenario_id", `Duplicate Scenario id: ${scenario.id}`);
|
|
263
|
+
}
|
|
264
|
+
scenarios.set(scenario.id, scenario);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return scenarios;
|
|
268
|
+
}
|