@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.
@@ -0,0 +1,335 @@
1
+ import { fail } from "./errors.mjs";
2
+
3
+ const NODE_TYPES = new Set([
4
+ "COMPONENT",
5
+ "COMPONENT_SET",
6
+ "FRAME",
7
+ "IMAGE",
8
+ "INSTANCE",
9
+ "RECTANGLE",
10
+ "TEXT",
11
+ ]);
12
+
13
+ function isRecord(value) {
14
+ return value !== null && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+
17
+ function stableId(value, prefix, code, path) {
18
+ if (
19
+ typeof value !== "string" ||
20
+ !value.startsWith(prefix) ||
21
+ !/^[a-zA-Z0-9_-]+$/.test(value)
22
+ ) {
23
+ fail(code, `${path} must begin with ${prefix}`, { path, value });
24
+ }
25
+ return value;
26
+ }
27
+
28
+ function nonEmpty(value, code, path) {
29
+ if (typeof value !== "string" || value.trim().length === 0) {
30
+ fail(code, `${path} must be a non-empty string`, { path, value });
31
+ }
32
+ return value.trim();
33
+ }
34
+
35
+ function assetReference(value, path, prefix) {
36
+ if (value === undefined) return undefined;
37
+ if (
38
+ !isRecord(value) ||
39
+ Object.keys(value).length !== 2 ||
40
+ typeof value.packageId !== "string" ||
41
+ !value.packageId.startsWith("pkg_") ||
42
+ typeof value.assetId !== "string" ||
43
+ !value.assetId.startsWith(prefix)
44
+ ) {
45
+ fail("invalid_asset_reference", `${path} is invalid`, { path });
46
+ }
47
+ return structuredClone(value);
48
+ }
49
+
50
+ function stringRecord(value, code, path) {
51
+ if (!isRecord(value)) fail(code, `${path} must contain an object`, { path });
52
+ for (const [field, child] of Object.entries(value)) {
53
+ if (field.length === 0 || typeof child !== "string" || child.length === 0) {
54
+ fail(code, `${path} must map strings to strings`, { path });
55
+ }
56
+ }
57
+ return structuredClone(value);
58
+ }
59
+
60
+ function validateNode(nodeValue, nodeId, path) {
61
+ if (!isRecord(nodeValue) || nodeValue.id !== nodeId) {
62
+ fail("node_id_mismatch", `${path} key and id must match`, { nodeId, path });
63
+ }
64
+ stableId(nodeId, "node_", "invalid_node_id", `${path}.id`);
65
+ if (!NODE_TYPES.has(nodeValue.type)) {
66
+ fail("unsupported_node_type", `${path}.type is unsupported`, {
67
+ path: `${path}.type`,
68
+ type: nodeValue.type,
69
+ });
70
+ }
71
+ nonEmpty(nodeValue.name, "invalid_node_name", `${path}.name`);
72
+ for (const field of ["height", "width", "x", "y"]) {
73
+ if (typeof nodeValue[field] !== "number" || !Number.isFinite(nodeValue[field])) {
74
+ fail("invalid_node_number", `${path}.${field} must be finite`, {
75
+ path: `${path}.${field}`,
76
+ });
77
+ }
78
+ }
79
+ if (!Array.isArray(nodeValue.children)) {
80
+ fail("invalid_node_children", `${path}.children must be an array`);
81
+ }
82
+ if (new Set(nodeValue.children).size !== nodeValue.children.length) {
83
+ fail("duplicate_node_child", `${path}.children contains duplicates`);
84
+ }
85
+ if (nodeValue.tokenBindings !== undefined) {
86
+ const bindings = stringRecordObject(
87
+ nodeValue.tokenBindings,
88
+ "invalid_token_bindings",
89
+ `${path}.tokenBindings`,
90
+ );
91
+ for (const [field, reference] of Object.entries(bindings)) {
92
+ assetReference(reference, `${path}.tokenBindings.${field}`, "tok_");
93
+ }
94
+ }
95
+ if (nodeValue.instance !== undefined) {
96
+ const instance = nodeValue.instance;
97
+ if (!isRecord(instance) || nodeValue.type !== "INSTANCE") {
98
+ fail("invalid_component_instance", `${path}.instance is invalid`);
99
+ }
100
+ assetReference(instance.component, `${path}.instance.component`, "cmp_");
101
+ stringRecord(
102
+ instance.variant,
103
+ "invalid_component_variant_selection",
104
+ `${path}.instance.variant`,
105
+ );
106
+ }
107
+ if (nodeValue.type === "TEXT" && typeof nodeValue.text !== "string") {
108
+ fail("invalid_text_content", `${path}.text must be a string`);
109
+ }
110
+ return structuredClone(nodeValue);
111
+ }
112
+
113
+ function stringRecordObject(value, code, path) {
114
+ if (!isRecord(value)) fail(code, `${path} must contain an object`, { path });
115
+ return value;
116
+ }
117
+
118
+ function validateNodeTree(nodesValue, rootId, path) {
119
+ if (!isRecord(nodesValue)) {
120
+ fail("invalid_variant_nodes", `${path}.nodes must contain an object`);
121
+ }
122
+ stableId(rootId, "node_", "invalid_node_id", `${path}.rootId`);
123
+ if (!nodesValue[rootId]) {
124
+ fail("missing_root_node", `${path}.rootId does not exist`, { rootId });
125
+ }
126
+ const nodes = {};
127
+ const parents = new Map();
128
+ for (const [nodeId, nodeValue] of Object.entries(nodesValue)) {
129
+ const node = validateNode(nodeValue, nodeId, `${path}.nodes.${nodeId}`);
130
+ nodes[nodeId] = node;
131
+ for (const childId of node.children) {
132
+ if (!nodesValue[childId]) {
133
+ fail("missing_child_node", `Node child does not exist: ${childId}`);
134
+ }
135
+ if (parents.has(childId)) {
136
+ fail("multiple_node_parents", `Node has more than one parent: ${childId}`);
137
+ }
138
+ parents.set(childId, nodeId);
139
+ }
140
+ }
141
+ if (parents.has(rootId)) {
142
+ fail("root_node_has_parent", `Variant root has a parent: ${rootId}`);
143
+ }
144
+ const visiting = new Set();
145
+ const visited = new Set();
146
+ const visit = (nodeId) => {
147
+ if (visiting.has(nodeId)) fail("node_cycle", `Node cycle includes ${nodeId}`);
148
+ if (visited.has(nodeId)) return;
149
+ visiting.add(nodeId);
150
+ for (const childId of nodes[nodeId].children) visit(childId);
151
+ visiting.delete(nodeId);
152
+ visited.add(nodeId);
153
+ };
154
+ visit(rootId);
155
+ if (visited.size !== Object.keys(nodes).length) {
156
+ fail("orphan_node", `${path} contains nodes outside its root tree`);
157
+ }
158
+ return nodes;
159
+ }
160
+
161
+ function parseAxis(value, path) {
162
+ if (!isRecord(value)) fail("invalid_variant_axis", `${path} is invalid`);
163
+ stableId(value.id, "axis_", "invalid_variant_axis_id", `${path}.id`);
164
+ const name = nonEmpty(value.name, "invalid_variant_axis_name", `${path}.name`);
165
+ if (value.role !== "configuration" && value.role !== "state") {
166
+ fail("invalid_variant_axis_role", `${path}.role is unsupported`);
167
+ }
168
+ let domain;
169
+ if (value.domain !== undefined) {
170
+ if (
171
+ !Array.isArray(value.domain) ||
172
+ value.domain.length === 0 ||
173
+ value.domain.some((entry) => typeof entry !== "string" || entry.length === 0) ||
174
+ new Set(value.domain).size !== value.domain.length
175
+ ) {
176
+ fail("invalid_variant_axis_domain", `${path}.domain is invalid`);
177
+ }
178
+ domain = [...value.domain];
179
+ }
180
+ if (value.role === "state" && !domain) {
181
+ fail("missing_state_domain", `${path}.domain is required for a state Axis`);
182
+ }
183
+ return { ...(domain ? { domain } : {}), id: value.id, name, role: value.role };
184
+ }
185
+
186
+ function selectionKey(selection) {
187
+ return Object.entries(selection)
188
+ .sort(([left], [right]) => left.localeCompare(right))
189
+ .map(([key, value]) => `${key}=${value}`)
190
+ .join("&");
191
+ }
192
+
193
+ function parseVariant(value, axes, path) {
194
+ if (!isRecord(value)) fail("invalid_variant", `${path} is invalid`);
195
+ stableId(value.id, "var_", "invalid_variant_id", `${path}.id`);
196
+ const selection = stringRecord(
197
+ value.selection,
198
+ "invalid_variant_selection",
199
+ `${path}.selection`,
200
+ );
201
+ for (const axis of axes) {
202
+ if (!Object.hasOwn(selection, axis.id)) {
203
+ fail("missing_variant_axis", `${path} must select ${axis.id}`);
204
+ }
205
+ if (axis.domain && !axis.domain.includes(selection[axis.id])) {
206
+ fail(
207
+ "variant_outside_domain",
208
+ `${path}.selection.${axis.id} is outside the Axis domain`,
209
+ );
210
+ }
211
+ }
212
+ for (const axisId of Object.keys(selection)) {
213
+ if (!axes.some((axis) => axis.id === axisId)) {
214
+ fail("unknown_variant_axis", `${path} selects unknown Axis ${axisId}`);
215
+ }
216
+ }
217
+ return {
218
+ id: value.id,
219
+ nodes: validateNodeTree(value.nodes, value.rootId, path),
220
+ rootId: value.rootId,
221
+ selection,
222
+ };
223
+ }
224
+
225
+ function optionalText(value, code, path) {
226
+ if (value === undefined) return undefined;
227
+ return nonEmpty(value, code, path);
228
+ }
229
+
230
+ function parseComponentSet(value, path) {
231
+ if (!isRecord(value)) fail("invalid_component_set", `${path} is invalid`);
232
+ stableId(value.id, "cmp_", "invalid_component_id", `${path}.id`);
233
+ const name = nonEmpty(value.name, "invalid_component_name", `${path}.name`);
234
+ if (!Array.isArray(value.axes) || !Array.isArray(value.variants)) {
235
+ fail("invalid_component_set", `${path} requires axes and variants arrays`);
236
+ }
237
+ const axes = value.axes.map((axis, index) =>
238
+ parseAxis(axis, `${path}.axes[${index}]`),
239
+ );
240
+ if (new Set(axes.map(({ id }) => id)).size !== axes.length) {
241
+ fail("duplicate_variant_axis", `${path}.axes contains duplicate ids`);
242
+ }
243
+ const variants = value.variants.map((variant, index) =>
244
+ parseVariant(variant, axes, `${path}.variants[${index}]`),
245
+ );
246
+ if (new Set(variants.map(({ id }) => id)).size !== variants.length) {
247
+ fail("duplicate_variant_id", `${path}.variants contains duplicate ids`);
248
+ }
249
+ const selections = variants.map(({ selection }) => selectionKey(selection));
250
+ if (new Set(selections).size !== selections.length) {
251
+ fail(
252
+ "duplicate_variant_selection",
253
+ `${path}.variants contains duplicate selections`,
254
+ );
255
+ }
256
+ if (
257
+ value.visibility !== undefined &&
258
+ value.visibility !== "private" &&
259
+ value.visibility !== "public"
260
+ ) {
261
+ fail("invalid_component_visibility", `${path}.visibility is invalid`);
262
+ }
263
+ if (value.deprecated !== undefined && typeof value.deprecated !== "boolean") {
264
+ fail("invalid_component_deprecated", `${path}.deprecated must be boolean`);
265
+ }
266
+ return {
267
+ axes,
268
+ category: optionalText(
269
+ value.category,
270
+ "invalid_component_category",
271
+ `${path}.category`,
272
+ ),
273
+ deprecated: value.deprecated === true,
274
+ description: optionalText(
275
+ value.description,
276
+ "invalid_component_description",
277
+ `${path}.description`,
278
+ ),
279
+ id: value.id,
280
+ name,
281
+ replacement: assetReference(value.replacement, `${path}.replacement`, "cmp_"),
282
+ replaces: assetReference(value.replaces, `${path}.replaces`, "cmp_"),
283
+ variants,
284
+ visibility: value.visibility === "private" ? "private" : "public",
285
+ };
286
+ }
287
+
288
+ export function parseComponentEntries(manifest, entries) {
289
+ const componentSets = new Map();
290
+ const locatedComponents = new Map();
291
+ for (const entry of manifest.entries.components) {
292
+ const value = entries[entry];
293
+ if (Array.isArray(value?.componentSets)) {
294
+ for (const [index, candidate] of value.componentSets.entries()) {
295
+ const componentSet = parseComponentSet(
296
+ candidate,
297
+ `${entry}.componentSets[${index}]`,
298
+ );
299
+ if (componentSets.has(componentSet.id)) {
300
+ fail(
301
+ "duplicate_component_id",
302
+ `Duplicate Component id: ${componentSet.id}`,
303
+ );
304
+ }
305
+ componentSets.set(componentSet.id, componentSet);
306
+ }
307
+ } else {
308
+ if (componentSets.has(value.id) || locatedComponents.has(value.id)) {
309
+ fail("duplicate_component_id", `Duplicate Component id: ${value.id}`);
310
+ }
311
+ locatedComponents.set(value.id, value);
312
+ }
313
+ }
314
+ for (const id of locatedComponents.keys()) {
315
+ if (componentSets.has(id)) {
316
+ fail("duplicate_component_id", `Duplicate Component id: ${id}`);
317
+ }
318
+ }
319
+ return { componentSets, locatedComponents };
320
+ }
321
+
322
+ export function findComponentVariant(componentSet, selection, options = {}) {
323
+ const key = selectionKey(selection);
324
+ const exact = componentSet.variants.find(
325
+ (variant) => selectionKey(variant.selection) === key,
326
+ );
327
+ if (exact) return { fallbackUsed: false, variant: exact };
328
+ return {
329
+ fallbackUsed: options.allowPreviewFallback === true,
330
+ variant:
331
+ options.allowPreviewFallback === true
332
+ ? (componentSet.variants[0] ?? null)
333
+ : null,
334
+ };
335
+ }
@@ -0,0 +1,248 @@
1
+ import { fail } from "./errors.mjs";
2
+
3
+ const CONTEXT_KINDS = new Set([
4
+ "accessibility",
5
+ "custom",
6
+ "density",
7
+ "locale",
8
+ "theme",
9
+ "viewport",
10
+ ]);
11
+
12
+ function isRecord(value) {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ function nonEmptyString(value, code, path) {
17
+ if (typeof value !== "string" || value.length === 0) {
18
+ fail(code, `${path} must be a non-empty string`, { path, value });
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function stableId(value, prefix, code, path) {
24
+ nonEmptyString(value, code, path);
25
+ if (!value.startsWith(prefix) || !/^[a-zA-Z0-9_-]+$/.test(value)) {
26
+ fail(code, `${path} must begin with ${prefix}`, { path, value });
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function stringRecord(value, code, path) {
32
+ if (!isRecord(value)) fail(code, `${path} must contain an object`, { path });
33
+ for (const [key, child] of Object.entries(value)) {
34
+ if (
35
+ typeof key !== "string" ||
36
+ key.length === 0 ||
37
+ typeof child !== "string" ||
38
+ child.length === 0
39
+ ) {
40
+ fail(code, `${path} must map non-empty strings to non-empty strings`, {
41
+ path,
42
+ });
43
+ }
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function parseAxis(value, path) {
49
+ if (!isRecord(value)) {
50
+ fail("invalid_context_axis", `${path} must contain an object`, { path });
51
+ }
52
+ const fields = new Set(["defaultValue", "id", "kind", "name", "values"]);
53
+ if (
54
+ Object.keys(value).length !== fields.size ||
55
+ Object.keys(value).some((field) => !fields.has(field))
56
+ ) {
57
+ fail("invalid_context_axis", `${path} fields are invalid`, { path });
58
+ }
59
+ stableId(value.id, "axis_", "invalid_context_axis_id", `${path}.id`);
60
+ nonEmptyString(value.name, "invalid_context_axis", `${path}.name`);
61
+ if (!CONTEXT_KINDS.has(value.kind)) {
62
+ fail("invalid_context_axis_kind", `${path}.kind is unsupported`, {
63
+ path: `${path}.kind`,
64
+ value: value.kind,
65
+ });
66
+ }
67
+ if (!Array.isArray(value.values) || value.values.length === 0) {
68
+ fail(
69
+ "invalid_context_axis_values",
70
+ `${path}.values must be a finite non-empty array`,
71
+ { path: `${path}.values` },
72
+ );
73
+ }
74
+ const ids = new Set();
75
+ const values = value.values.map((candidate, index) => {
76
+ const valuePath = `${path}.values[${index}]`;
77
+ if (
78
+ !isRecord(candidate) ||
79
+ Object.keys(candidate).length !== 2 ||
80
+ typeof candidate.id !== "string" ||
81
+ candidate.id.length === 0 ||
82
+ typeof candidate.name !== "string" ||
83
+ candidate.name.length === 0
84
+ ) {
85
+ fail(
86
+ "invalid_context_axis_value",
87
+ `${valuePath} requires id and name`,
88
+ { path: valuePath },
89
+ );
90
+ }
91
+ if (ids.has(candidate.id)) {
92
+ fail(
93
+ "duplicate_context_axis_value",
94
+ `${path}.values contains duplicate id ${candidate.id}`,
95
+ { path: `${path}.values`, valueId: candidate.id },
96
+ );
97
+ }
98
+ ids.add(candidate.id);
99
+ return structuredClone(candidate);
100
+ });
101
+ if (!ids.has(value.defaultValue)) {
102
+ fail(
103
+ "missing_context_default",
104
+ `${path}.defaultValue must name one declared value`,
105
+ { path: `${path}.defaultValue`, value: value.defaultValue },
106
+ );
107
+ }
108
+ return {
109
+ defaultValue: value.defaultValue,
110
+ id: value.id,
111
+ kind: value.kind,
112
+ name: value.name,
113
+ values,
114
+ };
115
+ }
116
+
117
+ function parseProfile(value, path) {
118
+ if (!isRecord(value)) {
119
+ fail("invalid_context_profile", `${path} must contain an object`, { path });
120
+ }
121
+ const fields = new Set(["default", "id", "name", "values"]);
122
+ if (Object.keys(value).some((field) => !fields.has(field))) {
123
+ fail("invalid_context_profile", `${path} fields are invalid`, { path });
124
+ }
125
+ stableId(value.id, "ctx_", "invalid_context_profile_id", `${path}.id`);
126
+ nonEmptyString(value.name, "invalid_context_profile", `${path}.name`);
127
+ if (value.default !== undefined && typeof value.default !== "boolean") {
128
+ fail(
129
+ "invalid_context_profile",
130
+ `${path}.default must be boolean when present`,
131
+ { path: `${path}.default` },
132
+ );
133
+ }
134
+ return {
135
+ default: value.default === true,
136
+ id: value.id,
137
+ name: value.name,
138
+ values: structuredClone(
139
+ stringRecord(
140
+ value.values,
141
+ "invalid_context_profile",
142
+ `${path}.values`,
143
+ ),
144
+ ),
145
+ };
146
+ }
147
+
148
+ export function parseContextEntries(manifest, entries) {
149
+ const axes = new Map();
150
+ const profiles = new Map();
151
+ for (const entry of manifest.entries.contexts) {
152
+ const value = entries[entry];
153
+ if (
154
+ !isRecord(value) ||
155
+ !Array.isArray(value.axes) ||
156
+ !Array.isArray(value.profiles) ||
157
+ Object.keys(value).some((field) => field !== "axes" && field !== "profiles")
158
+ ) {
159
+ fail(
160
+ "invalid_context_file",
161
+ `${entry} requires axes and profiles arrays`,
162
+ { entry },
163
+ );
164
+ }
165
+ for (const [index, candidate] of value.axes.entries()) {
166
+ const axis = parseAxis(candidate, `${entry}.axes[${index}]`);
167
+ if (axes.has(axis.id)) {
168
+ fail("duplicate_context_axis", `Duplicate Context Axis: ${axis.id}`, {
169
+ axisId: axis.id,
170
+ entry,
171
+ });
172
+ }
173
+ axes.set(axis.id, axis);
174
+ }
175
+ for (const [index, candidate] of value.profiles.entries()) {
176
+ const profile = parseProfile(candidate, `${entry}.profiles[${index}]`);
177
+ if (profiles.has(profile.id)) {
178
+ fail(
179
+ "duplicate_context_profile",
180
+ `Duplicate Context profile: ${profile.id}`,
181
+ { entry, profileId: profile.id },
182
+ );
183
+ }
184
+ profiles.set(profile.id, profile);
185
+ }
186
+ }
187
+ const defaults = [...profiles.values()].filter((profile) => profile.default);
188
+ if (defaults.length > 1) {
189
+ fail(
190
+ "multiple_default_context_profiles",
191
+ "At most one Context profile may be the default",
192
+ { profileIds: defaults.map(({ id }) => id) },
193
+ );
194
+ }
195
+ for (const profile of profiles.values()) {
196
+ for (const [axisId, valueId] of Object.entries(profile.values)) {
197
+ const axis = axes.get(axisId);
198
+ if (!axis || !axis.values.some(({ id }) => id === valueId)) {
199
+ fail(
200
+ "invalid_context_profile_value",
201
+ `Context profile ${profile.id} references an unknown Axis or value`,
202
+ {
203
+ axisId,
204
+ path: `contexts.${profile.id}.values.${axisId}`,
205
+ profileId: profile.id,
206
+ valueId,
207
+ },
208
+ );
209
+ }
210
+ }
211
+ }
212
+ return { axes, profiles };
213
+ }
214
+
215
+ export function combineContextAxes(product, foundation) {
216
+ const axes = new Map(foundation?.domain?.contextAxes ?? []);
217
+ for (const [axisId, axis] of product.domain.contextAxes) {
218
+ if (axes.has(axisId)) {
219
+ fail(
220
+ "duplicate_workspace_context_axis",
221
+ "Product cannot redefine a Foundation Context Axis",
222
+ { axisId, path: `contexts.${axisId}` },
223
+ );
224
+ }
225
+ axes.set(axisId, axis);
226
+ }
227
+ return axes;
228
+ }
229
+
230
+ export function resolveContext(product, foundation, selection = {}) {
231
+ const axes = combineContextAxes(product, foundation);
232
+ stringRecord(selection, "invalid_context_selection", "context");
233
+ for (const [axisId, valueId] of Object.entries(selection)) {
234
+ const axis = axes.get(axisId);
235
+ if (!axis || !axis.values.some(({ id }) => id === valueId)) {
236
+ fail(
237
+ "invalid_context_selection",
238
+ "Context selection references an unknown Axis or value",
239
+ { axisId, path: `context.${axisId}`, valueId },
240
+ );
241
+ }
242
+ }
243
+ return Object.fromEntries(
244
+ [...axes.values()]
245
+ .sort((left, right) => left.id.localeCompare(right.id))
246
+ .map((axis) => [axis.id, selection[axis.id] ?? axis.defaultValue]),
247
+ );
248
+ }