@gakim-digital/dexter-bridge 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ // The model turns the user's request into requirements it can check: a value
2
+ // a layer must carry, a link, a size, a distance, a layer that must stay
3
+ // inside its parent. This module checks them against live Framer data, so a
4
+ // requirement counts as met because Framer shows it, not because the model
5
+ // says so. Dexter does not model Framer's features: the model names the layer
6
+ // and the property, and this module reads what is there.
7
+
8
+ const MAX_SEARCH_DEPTH = 6;
9
+ const MAX_OBSERVED_CHARS = 1000;
10
+ const DEFAULT_TOLERANCE_PX = 2;
11
+ export const MEASURES = ['width', 'height', 'gap', 'insideParent'];
12
+
13
+ function isRecord(value) {
14
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
15
+ }
16
+
17
+ function text(value, maximum = 500) {
18
+ return typeof value === 'string'
19
+ ? value.trim().slice(0, maximum)
20
+ : '';
21
+ }
22
+
23
+ function uniqueStrings(values, maximum = 50) {
24
+ return Array.from(new Set(
25
+ (Array.isArray(values) ? values : [])
26
+ .map((value) => text(value, 500))
27
+ .filter(Boolean),
28
+ )).slice(0, maximum);
29
+ }
30
+
31
+ function contractError(code, message) {
32
+ return Object.assign(new Error(message), { code, statusCode: 400 });
33
+ }
34
+
35
+ function normalizeRequirement(raw, index) {
36
+ const id = text(raw?.id, 120);
37
+ const description = text(raw?.text, 500);
38
+ if (!isRecord(raw) || !id || description.length < 3) {
39
+ throw contractError(
40
+ 'FRAMER_REQUIREMENT_INVALID',
41
+ `Requirement ${index + 1} needs an id and the text of what must be true.`,
42
+ );
43
+ }
44
+ return { id, text: description, nodeIds: uniqueStrings(raw.nodeIds, 50) };
45
+ }
46
+
47
+ export function normalizeRequirements(rawRequirements) {
48
+ const requirements = (Array.isArray(rawRequirements) ? rawRequirements : [])
49
+ .slice(0, 40)
50
+ .map(normalizeRequirement);
51
+ const ids = new Set();
52
+ for (const requirement of requirements) {
53
+ if (ids.has(requirement.id)) {
54
+ throw contractError(
55
+ 'FRAMER_REQUIREMENT_INVALID',
56
+ `Requirement ids must be unique; "${requirement.id}" is duplicated.`,
57
+ );
58
+ }
59
+ ids.add(requirement.id);
60
+ }
61
+ return requirements;
62
+ }
63
+
64
+ export function mergeRequirements(current, next) {
65
+ const byId = new Map(current.map((item) => [item.id, item]));
66
+ next.forEach((item) => byId.set(item.id, item));
67
+ return [...byId.values()];
68
+ }
69
+
70
+ function finiteNumber(value) {
71
+ const number = Number(value);
72
+ return Number.isFinite(number) ? number : null;
73
+ }
74
+
75
+ export function normalizeChecks(rawChecks, requirements) {
76
+ const requirementIds = new Set(requirements.map((item) => item.id));
77
+ const checks = (Array.isArray(rawChecks) ? rawChecks : []).slice(0, 80);
78
+ if (!checks.length) {
79
+ throw contractError(
80
+ 'FRAMER_CHECKS_REQUIRED',
81
+ 'Send at least one check: a requirement id, a layer id, and either a property or a measure.',
82
+ );
83
+ }
84
+ return checks.map((raw, index) => {
85
+ const requirementId = text(raw?.requirementId, 120);
86
+ const nodeId = text(raw?.nodeId, 200);
87
+ const property = text(raw?.property, 120);
88
+ const measure = text(raw?.measure, 40);
89
+ const invalid = (reason) => contractError(
90
+ 'FRAMER_CHECK_INVALID',
91
+ `Check ${index + 1} ${reason}`,
92
+ );
93
+ if (!isRecord(raw) || !requirementIds.has(requirementId) || !nodeId) {
94
+ throw invalid(`must name a requirement from your plan (${[...requirementIds].join(', ') || 'none planned'}) and a layer id.`);
95
+ }
96
+ if (Boolean(property) === Boolean(measure)) {
97
+ throw invalid('needs either a property or a measure, not both.');
98
+ }
99
+ if (property) {
100
+ return { requirementId, nodeId, property, contains: text(raw.contains, 500) };
101
+ }
102
+ if (!MEASURES.includes(measure)) {
103
+ throw invalid(`has an unknown measure; use one of ${MEASURES.join(', ')}.`);
104
+ }
105
+ const otherNodeId = text(raw.otherNodeId, 200);
106
+ if (measure === 'gap' && !otherNodeId) throw invalid('measures a gap, so it needs otherNodeId.');
107
+ const expected = finiteNumber(raw.expected);
108
+ if (measure !== 'insideParent' && expected === null) {
109
+ throw invalid(`measures ${measure}, so it needs the expected number of pixels.`);
110
+ }
111
+ return {
112
+ requirementId,
113
+ nodeId,
114
+ measure,
115
+ ...(otherNodeId ? { otherNodeId } : {}),
116
+ ...(expected !== null ? { expected } : {}),
117
+ tolerance: Math.max(0, finiteNumber(raw.tolerance) ?? DEFAULT_TOLERANCE_PX),
118
+ };
119
+ });
120
+ }
121
+
122
+ export function checkNodeIds(checks) {
123
+ return uniqueStrings(checks.filter((check) => check.property).map((check) => check.nodeId), 200);
124
+ }
125
+
126
+ export function measureChecks(checks) {
127
+ return checks.filter((check) => check.measure);
128
+ }
129
+
130
+ function hasContent(value) {
131
+ if (value === undefined || value === null || value === '') return false;
132
+ if (Array.isArray(value)) return value.length > 0;
133
+ if (isRecord(value)) return Object.keys(value).length > 0;
134
+ return true;
135
+ }
136
+
137
+ // Finds the property on the node itself. Child nodes are skipped so a
138
+ // descendant cannot satisfy a claim about its parent. A dotted name such as
139
+ // `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
140
+ function propertyValues(node, property) {
141
+ const values = keyValues(node, property);
142
+ const [head, ...path] = property.split('.');
143
+ if (values.length > 0 || path.length === 0) return values;
144
+ return keyValues(node, head)
145
+ .map((value) => path.reduce(
146
+ (current, key) => (isRecord(current) ? current[key] : undefined),
147
+ value,
148
+ ))
149
+ .filter(hasContent);
150
+ }
151
+
152
+ function keyValues(node, property) {
153
+ const values = [];
154
+ const visit = (current, depth) => {
155
+ if (depth > MAX_SEARCH_DEPTH || values.length >= 10) return;
156
+ if (Array.isArray(current)) {
157
+ current.forEach((item) => visit(item, depth + 1));
158
+ return;
159
+ }
160
+ if (!isRecord(current)) return;
161
+ for (const [key, nested] of Object.entries(current)) {
162
+ if (key === 'children') continue;
163
+ if (key === property) {
164
+ if (hasContent(nested)) values.push(nested);
165
+ } else {
166
+ visit(nested, depth + 1);
167
+ }
168
+ }
169
+ };
170
+ visit(node, 0);
171
+ return values;
172
+ }
173
+
174
+ function serialized(value) {
175
+ try {
176
+ return JSON.stringify(value);
177
+ } catch {
178
+ return '';
179
+ }
180
+ }
181
+
182
+ function propertyVerdict(check, nodes) {
183
+ const node = nodes instanceof Map ? nodes.get(check.nodeId) : nodes?.[check.nodeId];
184
+ if (!node) return { ...check, verdict: 'unknown', reason: `layer ${check.nodeId} was not found` };
185
+ const values = propertyValues(node, check.property);
186
+ if (values.length === 0) {
187
+ return { ...check, verdict: 'unknown', reason: `${check.property} is not set on ${check.nodeId}` };
188
+ }
189
+ const observed = serialized(values.length === 1 ? values[0] : values);
190
+ if (check.contains && !observed.toLowerCase().includes(check.contains.toLowerCase())) {
191
+ return {
192
+ ...check,
193
+ verdict: 'contradicted',
194
+ reason: `${check.property} on ${check.nodeId} is ${observed.slice(0, 200)}, not ${check.contains}`,
195
+ observedValue: observed.slice(0, MAX_OBSERVED_CHARS),
196
+ };
197
+ }
198
+ return { ...check, verdict: 'verified', observedValue: observed.slice(0, MAX_OBSERVED_CHARS) };
199
+ }
200
+
201
+ // `measured` holds what the measuring program found for each measure check,
202
+ // in the same order: { value } in pixels, { inside, overflow } for
203
+ // insideParent, or { error }.
204
+ function measureVerdict(check, measured) {
205
+ if (!measured || measured.error) {
206
+ return { ...check, verdict: 'unknown', reason: measured?.error || `${check.nodeId} could not be measured` };
207
+ }
208
+ if (check.measure === 'insideParent') {
209
+ return measured.inside
210
+ ? { ...check, verdict: 'verified', observedValue: 'inside its parent' }
211
+ : {
212
+ ...check,
213
+ verdict: 'contradicted',
214
+ reason: `${check.nodeId} sticks out of its parent by ${measured.overflow}px`,
215
+ observedValue: `overflows by ${measured.overflow}px`,
216
+ };
217
+ }
218
+ const value = Number(measured.value);
219
+ const label = check.measure === 'gap' ? `gap between ${check.nodeId} and ${check.otherNodeId}` : `${check.measure} of ${check.nodeId}`;
220
+ if (Math.abs(value - check.expected) <= check.tolerance) {
221
+ return { ...check, verdict: 'verified', observedValue: `${value}px` };
222
+ }
223
+ return {
224
+ ...check,
225
+ verdict: 'contradicted',
226
+ reason: `${label} measures ${value}px, not ${check.expected}px`,
227
+ observedValue: `${value}px`,
228
+ };
229
+ }
230
+
231
+ export function verifyChecks({ checks, nodes, measurements = [] }) {
232
+ let measureIndex = 0;
233
+ return checks.map((check) => (check.property
234
+ ? propertyVerdict(check, nodes)
235
+ : measureVerdict(check, measurements[measureIndex++])));
236
+ }
237
+
238
+ // Summarizes the latest check results for every requirement. A requirement is
239
+ // met once every layer it names has a verified check (or, naming none, once
240
+ // one check verified it) and no check contradicts it.
241
+ // status: missing (a requirement or one of its layers was never checked),
242
+ // contradicted (Framer shows something else), unverified (a property or
243
+ // layer could not be found), or verified.
244
+ export function summarizeRequirements(requirements, checkResults) {
245
+ const items = requirements.map((requirement) => {
246
+ const results = checkResults.filter((result) => result.requirementId === requirement.id);
247
+ const verifiedNodeIds = new Set(
248
+ results.filter((result) => result.verdict === 'verified').map((result) => result.nodeId),
249
+ );
250
+ const contradictions = results
251
+ .filter((result) => result.verdict === 'contradicted')
252
+ .map((result) => result.reason);
253
+ const checkedNodeIds = new Set(results.map((result) => result.nodeId));
254
+ const uncheckedNodeIds = requirement.nodeIds.filter((nodeId) => !checkedNodeIds.has(nodeId));
255
+ const covered = requirement.nodeIds.length > 0
256
+ ? requirement.nodeIds.every((nodeId) => verifiedNodeIds.has(nodeId))
257
+ : verifiedNodeIds.size > 0;
258
+ return {
259
+ requirementId: requirement.id,
260
+ text: requirement.text,
261
+ checked: results.length > 0 && uncheckedNodeIds.length === 0,
262
+ verdict: contradictions.length > 0 ? 'contradicted' : covered ? 'verified' : 'unknown',
263
+ uncheckedNodeIds,
264
+ contradictions: uniqueStrings(contradictions, 20),
265
+ unknowns: uniqueStrings(
266
+ results.filter((result) => result.verdict === 'unknown').map((result) => result.reason),
267
+ 20,
268
+ ),
269
+ };
270
+ });
271
+ const status = items.some((item) => !item.checked)
272
+ ? 'missing'
273
+ : items.some((item) => item.verdict === 'contradicted')
274
+ ? 'contradicted'
275
+ : items.some((item) => item.verdict === 'unknown')
276
+ ? 'unverified'
277
+ : 'verified';
278
+ return { status, requirements: items };
279
+ }
280
+
281
+ // Compares two snapshots of layers that had to stay as they were. A snapshot
282
+ // maps a layer id to { name, rect, props } where props is the serialized
283
+ // layer without children.
284
+ export function unchangedViolations(before, after, tolerance = 1) {
285
+ const changes = [];
286
+ for (const [id, earlier] of Object.entries(before || {})) {
287
+ const later = after?.[id];
288
+ const name = earlier.name || id;
289
+ if (!later) {
290
+ changes.push(`${name} was removed`);
291
+ continue;
292
+ }
293
+ const a = earlier.rect;
294
+ const b = later.rect;
295
+ if (a && b && ['x', 'y', 'width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
296
+ changes.push(`${name} moved or resized (${a.width}×${a.height} at ${a.x},${a.y} → ${b.width}×${b.height} at ${b.x},${b.y})`);
297
+ } else if (earlier.props !== later.props) {
298
+ changes.push(`${name} has different settings`);
299
+ }
300
+ if (changes.length >= 20) break;
301
+ }
302
+ return changes;
303
+ }