@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,272 @@
1
+ import { combineContextAxes, resolveContext } from "./contexts.mjs";
2
+ import { listEffectiveTokens } from "./effective-tokens.mjs";
3
+
4
+ const FIELD_TYPES = new Map([
5
+ ["backgroundBlur", ["number", "other"]],
6
+ ["blur", ["number", "other"]],
7
+ ["cornerRadius", ["border-radius", "dimensions", "number"]],
8
+ ["fill", ["color"]],
9
+ ["fontFamily", ["font-family", "string"]],
10
+ ["fontSize", ["dimensions", "font-size", "number"]],
11
+ ["fontWeight", ["font-weight", "number"]],
12
+ ["height", ["dimensions", "number", "sizing"]],
13
+ ["itemSpacing", ["dimensions", "number", "spacing"]],
14
+ ["opacity", ["number", "opacity"]],
15
+ ["paddingBottom", ["dimensions", "number", "spacing"]],
16
+ ["paddingLeft", ["dimensions", "number", "spacing"]],
17
+ ["paddingRight", ["dimensions", "number", "spacing"]],
18
+ ["paddingTop", ["dimensions", "number", "spacing"]],
19
+ ["shadow", ["shadow"]],
20
+ ["typography", ["typography"]],
21
+ ["width", ["dimensions", "number", "sizing"]],
22
+ ]);
23
+
24
+ const NAMED_COLORS = new Map([
25
+ ["black", [0, 0, 0, 255]], ["blue", [0, 0, 255, 255]],
26
+ ["gray", [128, 128, 128, 255]], ["green", [0, 128, 0, 255]],
27
+ ["grey", [128, 128, 128, 255]], ["red", [255, 0, 0, 255]],
28
+ ["transparent", [0, 0, 0, 0]], ["white", [255, 255, 255, 255]],
29
+ ]);
30
+
31
+ function compareText(left, right) {
32
+ return left < right ? -1 : left > right ? 1 : 0;
33
+ }
34
+
35
+ function comparable(value) {
36
+ if (Array.isArray(value)) return value.map(comparable);
37
+ if (value !== null && typeof value === "object") {
38
+ return Object.fromEntries(Object.entries(value)
39
+ .sort(([left], [right]) => compareText(left, right))
40
+ .map(([key, child]) => [key, comparable(child)]));
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function equalValue(left, right) {
46
+ return JSON.stringify(comparable(left)) === JSON.stringify(comparable(right));
47
+ }
48
+
49
+ function clamp(value, lower, upper) {
50
+ return Math.min(Math.max(value, lower), upper);
51
+ }
52
+
53
+ function colorComponents(value) {
54
+ if (value !== null && typeof value === "object" &&
55
+ String(value.colorSpace ?? "").toLowerCase() === "srgb" &&
56
+ Array.isArray(value.components) && value.components.length === 3 &&
57
+ value.components.every((part) => typeof part === "number" && Number.isFinite(part))) {
58
+ const alpha = value.alpha === undefined ? 1 : value.alpha;
59
+ if (typeof alpha !== "number" || !Number.isFinite(alpha)) return undefined;
60
+ return [...value.components.map((part) => Math.round(clamp(part, 0, 1) * 255)),
61
+ Math.round(clamp(alpha, 0, 1) * 255)];
62
+ }
63
+ if (typeof value !== "string") return undefined;
64
+ const source = value.trim().toLowerCase();
65
+ if (NAMED_COLORS.has(source)) return [...NAMED_COLORS.get(source)];
66
+ if (source.startsWith("#")) {
67
+ const hex = source.slice(1);
68
+ if (![3, 4, 6, 8].includes(hex.length) || !/^[0-9a-f]+$/.test(hex)) return undefined;
69
+ const pairs = hex.length <= 4 ? [...hex].map((part) => part + part) : hex.match(/../g);
70
+ return [Number.parseInt(pairs[0], 16), Number.parseInt(pairs[1], 16),
71
+ Number.parseInt(pairs[2], 16), pairs[3] === undefined ? 255 : Number.parseInt(pairs[3], 16)];
72
+ }
73
+ const match = /^rgba?\(([^)]+)\)$/.exec(source);
74
+ if (!match) return undefined;
75
+ const parts = match[1].split(",").map((part) => Number(part.trim()));
76
+ if ((parts.length !== 3 && parts.length !== 4) || !parts.every(Number.isFinite)) return undefined;
77
+ return [clamp(Math.round(parts[0]), 0, 255), clamp(Math.round(parts[1]), 0, 255),
78
+ clamp(Math.round(parts[2]), 0, 255),
79
+ parts[3] === undefined ? 255 : clamp(Math.round(parts[3] * 255), 0, 255)];
80
+ }
81
+
82
+ function valueDistance(requested, candidate) {
83
+ const requestedColor = colorComponents(requested);
84
+ const candidateColor = colorComponents(candidate);
85
+ if (requestedColor && candidateColor) {
86
+ return Math.sqrt(requestedColor.reduce(
87
+ (sum, component, index) => sum + (component - candidateColor[index]) ** 2, 0));
88
+ }
89
+ if (typeof requested === "number" && typeof candidate === "number") return Math.abs(requested - candidate);
90
+ return equalValue(requested, candidate) ? 0 : Number.POSITIVE_INFINITY;
91
+ }
92
+
93
+ function contextSelections(product, options) {
94
+ if (Object.hasOwn(options, "context")) return [resolveContext(product, options.foundation, options.context)];
95
+ const axes = [...combineContextAxes(product, options.foundation).values()]
96
+ .sort((left, right) => compareText(left.id, right.id));
97
+ return axes.reduce((selections, axis) => selections.flatMap((selection) =>
98
+ axis.values.map(({ id }) => ({ ...selection, [axis.id]: id }))), [{}]);
99
+ }
100
+
101
+ function createSearchIndex(product, options) {
102
+ const contexts = contextSelections(product, options);
103
+ const grouped = new Map();
104
+ for (const context of contexts) {
105
+ for (const effective of listEffectiveTokens(product, {
106
+ context,
107
+ foundation: options.foundation,
108
+ libraries: options.libraries,
109
+ })) {
110
+ const key = JSON.stringify([effective.target.packageId, effective.target.assetId,
111
+ effective.token.type, comparable(effective.value)]);
112
+ const existing = grouped.get(key);
113
+ if (existing) {
114
+ existing.contexts.push(structuredClone(context));
115
+ continue;
116
+ }
117
+ grouped.set(key, {
118
+ contexts: [structuredClone(context)], deprecated: effective.token.deprecated,
119
+ description: effective.token.description, packageId: effective.target.packageId,
120
+ path: effective.token.path, reference: structuredClone(effective.target),
121
+ sourcePackageId: effective.sourcePackageId, sourceTokenId: effective.sourceTokenId,
122
+ type: effective.token.type, value: structuredClone(effective.value),
123
+ });
124
+ }
125
+ }
126
+ return { contexts, items: [...grouped.values()] };
127
+ }
128
+
129
+ function searchItems(product, options, suppliedIndex) {
130
+ const query = String(options.query ?? "").trim().toLowerCase();
131
+ const requestedTypes = options.types ? new Set(options.types) : options.type ? new Set([options.type]) : undefined;
132
+ const hasValue = Object.hasOwn(options, "value");
133
+ const index = suppliedIndex ?? createSearchIndex(product, options);
134
+ const items = index.items
135
+ .filter((item) => item.deprecated !== true)
136
+ .filter((item) => !requestedTypes || requestedTypes.has(item.type))
137
+ .filter((item) => !query || [item.path, item.description, item.type].filter(Boolean)
138
+ .some((value) => String(value).toLowerCase().includes(query)))
139
+ .map((item) => {
140
+ const distance = hasValue ? valueDistance(options.value, item.value) : undefined;
141
+ return { ...item, distance, exactValue: hasValue ? distance === 0 : undefined };
142
+ })
143
+ .filter((item) => !hasValue || Number.isFinite(item.distance))
144
+ .sort((left, right) => {
145
+ if (hasValue && left.distance !== right.distance) return left.distance - right.distance;
146
+ return compareText(left.path, right.path) || compareText(left.packageId, right.packageId) ||
147
+ compareText(JSON.stringify(comparable(left.value)), JSON.stringify(comparable(right.value)));
148
+ });
149
+ return { contexts: index.contexts, items };
150
+ }
151
+
152
+ export function searchEffectiveTokens(product, options = {}) {
153
+ const requestedLimit = Number(options.limit ?? 20);
154
+ const limit = Number.isSafeInteger(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 100)) : 20;
155
+ const searched = searchItems(product, options);
156
+ return {
157
+ contextScope: { contexts: searched.contexts, mode: Object.hasOwn(options, "context") ? "explicit" : "all" },
158
+ items: searched.items.slice(0, limit), query: options.query ?? null,
159
+ requestedType: options.type ?? null,
160
+ requestedValue: Object.hasOwn(options, "value") ? structuredClone(options.value) : null,
161
+ total: searched.items.length,
162
+ };
163
+ }
164
+
165
+ function presentationNode(snapshot, operation) {
166
+ const entry = snapshot.manifest.entries.screens.find((candidate) => snapshot.entries[candidate].id === operation.screenId);
167
+ const screen = snapshot.entries[entry];
168
+ if (!screen) return undefined;
169
+ // Advice stays available for batches that target a screen's base
170
+ // presentation without repeating its id.
171
+ const presentation = operation.presentationId === undefined
172
+ ? screen.presentations.find(({ id }) => id === screen.basePresentationId)
173
+ : screen.presentations.find(({ id }) => id === operation.presentationId);
174
+ return presentation?.nodes[operation.nodeId ?? operation.node?.id];
175
+ }
176
+
177
+ function componentNode(snapshot, operation) {
178
+ for (const entry of snapshot.manifest.entries.components) {
179
+ const componentSet = snapshot.entries[entry]?.componentSets?.find(({ id }) => id === operation.componentSetId);
180
+ const variant = componentSet?.variants.find(({ id }) => id === operation.variantId);
181
+ if (variant?.nodes[operation.nodeId]) return variant.nodes[operation.nodeId];
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ function nodesInPresentation(presentation) {
187
+ return Object.values(presentation?.nodes ?? {}).map((node) => ({ changes: node, node }));
188
+ }
189
+
190
+ function nodesForOperation(snapshot, operation) {
191
+ if (["add-presentation-node", "update-node", "update-presentation-node"].includes(operation.type)) {
192
+ const node = presentationNode(snapshot, operation);
193
+ return node ? [{ changes: operation.node ?? operation.changes ?? {}, node }] : [];
194
+ }
195
+ if (operation.type === "update-component-node") {
196
+ const node = componentNode(snapshot, operation);
197
+ return node ? [{ changes: operation.changes ?? {}, node }] : [];
198
+ }
199
+ if (operation.type === "add-presentation") return nodesInPresentation(operation.presentation);
200
+ if (operation.type === "put-screen") return (operation.screen?.presentations ?? []).flatMap(nodesInPresentation);
201
+ if (operation.type === "put-component-set") {
202
+ return (operation.componentSet?.variants ?? []).flatMap((variant) =>
203
+ Object.values(variant.nodes ?? {}).map((node) => ({ changes: node, node })));
204
+ }
205
+ if (operation.type === "put-variant") {
206
+ return Object.values(operation.variant?.nodes ?? {}).map((node) => ({ changes: node, node }));
207
+ }
208
+ return [];
209
+ }
210
+
211
+ function assignments(changes) {
212
+ const result = [];
213
+ for (const [field, types] of FIELD_TYPES) {
214
+ if (field === "fill" || field === "typography") continue;
215
+ if (Object.hasOwn(changes, field) && changes[field] !== null) {
216
+ result.push({ bindingField: field, field, types, value: changes[field] });
217
+ }
218
+ }
219
+ for (const [index, paint] of (changes.fills ?? []).entries()) {
220
+ if (paint?.color !== undefined) result.push({ bindingField: `fills.${index}`,
221
+ field: `fills.${index}`, types: FIELD_TYPES.get("fill"), value: paint.color });
222
+ }
223
+ if (changes.textStyle !== undefined && changes.textStyle !== null) {
224
+ result.push({ bindingField: "typography", field: "textStyle",
225
+ types: FIELD_TYPES.get("typography"), value: changes.textStyle });
226
+ for (const field of ["fontFamily", "fontSize", "fontWeight"]) {
227
+ if (Object.hasOwn(changes.textStyle, field)) result.push({ bindingField: field,
228
+ field: `textStyle.${field}`, types: FIELD_TYPES.get(field), value: changes.textStyle[field] });
229
+ }
230
+ }
231
+ return result;
232
+ }
233
+
234
+ function hasBinding(node, bindingField) {
235
+ const bindings = node.tokenBindings ?? {};
236
+ if (Object.hasOwn(bindings, bindingField)) return true;
237
+ if (bindingField.startsWith("fills.") && Object.hasOwn(bindings, "fill")) return true;
238
+ return ["fontFamily", "fontSize", "fontWeight"].includes(bindingField) && Object.hasOwn(bindings, "typography");
239
+ }
240
+
241
+ export function designTokenWarningsForBatch(product, batch, options = {}) {
242
+ const warnings = [];
243
+ // Context 展开和 Effective Token 解析每批只做一次;字段只做类型和值过滤。
244
+ const searchIndex = createSearchIndex(product, options);
245
+ for (const [operationIndex, operation] of (batch.operations ?? []).entries()) {
246
+ for (const { changes, node } of nodesForOperation(product, operation)) {
247
+ for (const assignment of assignments(changes)) {
248
+ if (hasBinding(node, assignment.bindingField)) continue;
249
+ const searched = searchItems(product,
250
+ { ...options, types: assignment.types, value: assignment.value }, searchIndex);
251
+ const suggestions = searched.items.slice(0, 3);
252
+ const exactSuggestion = suggestions.find(({ exactValue }) => exactValue);
253
+ warnings.push({
254
+ code: exactSuggestion ? "design_token_not_used" : "design_token_value_unmatched",
255
+ field: assignment.field,
256
+ contextScope: { contexts: searched.contexts,
257
+ mode: Object.hasOwn(options, "context") ? "explicit" : "all" },
258
+ match: exactSuggestion ? "exact" : "none",
259
+ message: exactSuggestion
260
+ ? `A Design Token resolves to the hard-coded ${assignment.field} value in one or more Contexts`
261
+ : `No Design Token resolves to the hard-coded ${assignment.field} value; confirm that the raw value is intentional`,
262
+ nodeId: node.id, operationIndex,
263
+ ...(exactSuggestion ? { recommendedBinding: { field: assignment.bindingField,
264
+ reference: structuredClone(exactSuggestion.reference) } } : {}),
265
+ severity: "warning", suggestions, valueSource: "raw-unbound",
266
+ value: structuredClone(assignment.value),
267
+ });
268
+ }
269
+ }
270
+ }
271
+ return warnings;
272
+ }