@mp3wizard/figma-console-mcp 1.22.6 → 1.25.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/README.md +15 -38
- package/dist/cloudflare/core/design-code-tools.js +3 -29
- package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
- package/dist/cloudflare/core/diff/diff-engine.js +334 -0
- package/dist/cloudflare/core/diff/property-compare.js +36 -0
- package/dist/cloudflare/core/diff/version-cache.js +74 -0
- package/dist/cloudflare/core/figma-api.js +19 -0
- package/dist/cloudflare/core/version-tools.js +1158 -0
- package/dist/cloudflare/core/websocket-server.js +72 -0
- package/dist/cloudflare/index.js +17 -13
- package/dist/core/design-code-tools.d.ts +1 -12
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +3 -29
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/diff/changelog-formatter.d.ts +35 -0
- package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
- package/dist/core/diff/changelog-formatter.js +276 -0
- package/dist/core/diff/changelog-formatter.js.map +1 -0
- package/dist/core/diff/diff-engine.d.ts +127 -0
- package/dist/core/diff/diff-engine.d.ts.map +1 -0
- package/dist/core/diff/diff-engine.js +335 -0
- package/dist/core/diff/diff-engine.js.map +1 -0
- package/dist/core/diff/property-compare.d.ts +19 -0
- package/dist/core/diff/property-compare.d.ts.map +1 -0
- package/dist/core/diff/property-compare.js +37 -0
- package/dist/core/diff/property-compare.js.map +1 -0
- package/dist/core/diff/version-cache.d.ts +40 -0
- package/dist/core/diff/version-cache.d.ts.map +1 -0
- package/dist/core/diff/version-cache.js +75 -0
- package/dist/core/diff/version-cache.js.map +1 -0
- package/dist/core/figma-api.d.ts +29 -0
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +19 -0
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/version-tools.d.ts +59 -0
- package/dist/core/version-tools.d.ts.map +1 -0
- package/dist/core/version-tools.js +1159 -0
- package/dist/core/version-tools.js.map +1 -0
- package/dist/core/websocket-server.d.ts +43 -0
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +72 -0
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +16 -0
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +49 -1
- package/figma-desktop-bridge/ui.html +13 -0
- package/package.json +89 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff engine — pure functions for comparing Figma file/node snapshots
|
|
3
|
+
* across two versions.
|
|
4
|
+
*
|
|
5
|
+
* Scope (v1):
|
|
6
|
+
* - Page-structure diff (added/removed/renamed pages) from depth=1 file fetches
|
|
7
|
+
* - Per-node diff for scoped fetches: name, description, children added/removed,
|
|
8
|
+
* componentPropertyDefinitions changes (for COMPONENT_SET), boundVariables changes
|
|
9
|
+
*
|
|
10
|
+
* NOT in scope (deferred):
|
|
11
|
+
* - Variable VALUE history (Figma REST does not expose this; planned forward
|
|
12
|
+
* ledger will fill the gap going forward, never retroactively)
|
|
13
|
+
* - Style-content diffs (only style add/remove via reachable styles map)
|
|
14
|
+
* - Inside-fills/strokes binding diffs (top-level boundVariables only for v1)
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Compare two file documents (from /v1/files/:key?version=X with depth=1 or higher).
|
|
18
|
+
* Both arguments should be the `document` field of a Figma file response.
|
|
19
|
+
*/
|
|
20
|
+
export function diffPageStructure(fromDoc, toDoc) {
|
|
21
|
+
const fromPages = extractPages(fromDoc);
|
|
22
|
+
const toPages = extractPages(toDoc);
|
|
23
|
+
const fromById = new Map(fromPages.map((p) => [p.id, p]));
|
|
24
|
+
const toById = new Map(toPages.map((p) => [p.id, p]));
|
|
25
|
+
const added = [];
|
|
26
|
+
const removed = [];
|
|
27
|
+
const renamed = [];
|
|
28
|
+
for (const p of toPages) {
|
|
29
|
+
if (!fromById.has(p.id)) {
|
|
30
|
+
added.push(p);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const old = fromById.get(p.id);
|
|
34
|
+
if (old.name !== p.name) {
|
|
35
|
+
renamed.push({ id: p.id, old_name: old.name, new_name: p.name });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const p of fromPages) {
|
|
40
|
+
if (!toById.has(p.id))
|
|
41
|
+
removed.push(p);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
pages_added: added,
|
|
45
|
+
pages_removed: removed,
|
|
46
|
+
pages_renamed: renamed,
|
|
47
|
+
summary: {
|
|
48
|
+
added: added.length,
|
|
49
|
+
removed: removed.length,
|
|
50
|
+
renamed: renamed.length,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function extractPages(doc) {
|
|
55
|
+
const children = doc?.children;
|
|
56
|
+
if (!Array.isArray(children))
|
|
57
|
+
return [];
|
|
58
|
+
return children
|
|
59
|
+
.filter((c) => c && typeof c.id === "string" && typeof c.name === "string")
|
|
60
|
+
.map((c) => ({ id: c.id, name: c.name }));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Diff a COMPONENT_SET's componentPropertyDefinitions map.
|
|
64
|
+
* Each definition is keyed by `propName#nodeId` in Figma's response.
|
|
65
|
+
*/
|
|
66
|
+
export function diffComponentPropertyDefinitions(fromDefs, toDefs) {
|
|
67
|
+
const from = fromDefs ?? {};
|
|
68
|
+
const to = toDefs ?? {};
|
|
69
|
+
const added = [];
|
|
70
|
+
const removed = [];
|
|
71
|
+
const typeChanged = [];
|
|
72
|
+
const defaultChanged = [];
|
|
73
|
+
for (const key of Object.keys(to)) {
|
|
74
|
+
const toDef = to[key];
|
|
75
|
+
if (!(key in from)) {
|
|
76
|
+
added.push({ name: key, type: toDef.type, default_value: toDef.defaultValue });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const fromDef = from[key];
|
|
80
|
+
if (fromDef.type !== toDef.type) {
|
|
81
|
+
typeChanged.push({ name: key, from_type: fromDef.type, to_type: toDef.type });
|
|
82
|
+
}
|
|
83
|
+
if (!deepEqual(fromDef.defaultValue, toDef.defaultValue)) {
|
|
84
|
+
defaultChanged.push({
|
|
85
|
+
name: key,
|
|
86
|
+
from_default: fromDef.defaultValue,
|
|
87
|
+
to_default: toDef.defaultValue,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const key of Object.keys(from)) {
|
|
93
|
+
if (!(key in to)) {
|
|
94
|
+
const fromDef = from[key];
|
|
95
|
+
removed.push({ name: key, type: fromDef.type, default_value: fromDef.defaultValue });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
added,
|
|
100
|
+
removed,
|
|
101
|
+
type_changed: typeChanged,
|
|
102
|
+
default_changed: defaultChanged,
|
|
103
|
+
summary: {
|
|
104
|
+
added: added.length,
|
|
105
|
+
removed: removed.length,
|
|
106
|
+
type_changed: typeChanged.length,
|
|
107
|
+
default_changed: defaultChanged.length,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Walk both node trees in parallel (matched by node id), comparing top-level
|
|
113
|
+
* boundVariables. Inside-fills/strokes binding diffs are explicitly NOT included
|
|
114
|
+
* in v1 — they add complexity for marginal additional value over node-level deltas.
|
|
115
|
+
*/
|
|
116
|
+
export function collectBindingChanges(fromNode, toNode) {
|
|
117
|
+
const changes = [];
|
|
118
|
+
walkBoth(fromNode, toNode, (a, b) => {
|
|
119
|
+
const aBindings = extractTopLevelBindings(a);
|
|
120
|
+
const bBindings = extractTopLevelBindings(b);
|
|
121
|
+
const allKeys = new Set([...Object.keys(aBindings), ...Object.keys(bBindings)]);
|
|
122
|
+
for (const key of allKeys) {
|
|
123
|
+
const fromId = aBindings[key] ?? null;
|
|
124
|
+
const toId = bBindings[key] ?? null;
|
|
125
|
+
if (fromId === toId)
|
|
126
|
+
continue;
|
|
127
|
+
let kind;
|
|
128
|
+
if (fromId === null)
|
|
129
|
+
kind = "added";
|
|
130
|
+
else if (toId === null)
|
|
131
|
+
kind = "removed";
|
|
132
|
+
else
|
|
133
|
+
kind = "rebound";
|
|
134
|
+
changes.push({
|
|
135
|
+
node_id: (b ?? a).id,
|
|
136
|
+
node_name: (b ?? a).name ?? "",
|
|
137
|
+
property: key,
|
|
138
|
+
from_variable_id: fromId,
|
|
139
|
+
to_variable_id: toId,
|
|
140
|
+
change_kind: kind,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return changes;
|
|
145
|
+
}
|
|
146
|
+
function extractTopLevelBindings(node) {
|
|
147
|
+
const out = {};
|
|
148
|
+
const bv = node?.boundVariables;
|
|
149
|
+
if (!bv || typeof bv !== "object")
|
|
150
|
+
return out;
|
|
151
|
+
for (const [prop, ref] of Object.entries(bv)) {
|
|
152
|
+
if (ref && typeof ref === "object") {
|
|
153
|
+
// Single VARIABLE_ALIAS reference
|
|
154
|
+
if (ref.type === "VARIABLE_ALIAS" && typeof ref.id === "string") {
|
|
155
|
+
out[prop] = ref.id;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Array of refs (e.g., fills binding can be an array — surface first match for v1)
|
|
159
|
+
if (Array.isArray(ref)) {
|
|
160
|
+
const first = ref.find((r) => r && r.type === "VARIABLE_ALIAS" && typeof r.id === "string");
|
|
161
|
+
if (first)
|
|
162
|
+
out[`${prop}[0]`] = first.id;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function walkBoth(fromNode, toNode, visit) {
|
|
169
|
+
if (!fromNode && !toNode)
|
|
170
|
+
return;
|
|
171
|
+
visit(fromNode, toNode);
|
|
172
|
+
const fromChildren = Array.isArray(fromNode?.children) ? fromNode.children : [];
|
|
173
|
+
const toChildren = Array.isArray(toNode?.children) ? toNode.children : [];
|
|
174
|
+
const byIdFrom = new Map();
|
|
175
|
+
for (const c of fromChildren)
|
|
176
|
+
if (c?.id)
|
|
177
|
+
byIdFrom.set(c.id, c);
|
|
178
|
+
const byIdTo = new Map();
|
|
179
|
+
for (const c of toChildren)
|
|
180
|
+
if (c?.id)
|
|
181
|
+
byIdTo.set(c.id, c);
|
|
182
|
+
const allIds = new Set([...byIdFrom.keys(), ...byIdTo.keys()]);
|
|
183
|
+
for (const id of allIds) {
|
|
184
|
+
walkBoth(byIdFrom.get(id), byIdTo.get(id), visit);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Diff a single node (typically a COMPONENT_SET) at depth=2. Either side may be
|
|
189
|
+
* null/undefined to represent "newly added" or "removed entirely."
|
|
190
|
+
*/
|
|
191
|
+
export function diffNode(fromNode, toNode, mode = "standard") {
|
|
192
|
+
const id = (toNode ?? fromNode)?.id ?? "";
|
|
193
|
+
const name = (toNode ?? fromNode)?.name ?? "";
|
|
194
|
+
const type = (toNode ?? fromNode)?.type ?? "";
|
|
195
|
+
const out = {
|
|
196
|
+
node_id: id,
|
|
197
|
+
node_name: name,
|
|
198
|
+
node_type: type,
|
|
199
|
+
name_changed: null,
|
|
200
|
+
description_changed: null,
|
|
201
|
+
children_added: [],
|
|
202
|
+
children_removed: [],
|
|
203
|
+
component_properties: null,
|
|
204
|
+
binding_changes: [],
|
|
205
|
+
change_count: 0,
|
|
206
|
+
notes: [],
|
|
207
|
+
};
|
|
208
|
+
if (!fromNode && toNode) {
|
|
209
|
+
out.notes.push("Node was added in the target version (no prior state to compare).");
|
|
210
|
+
out.change_count = 1;
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
if (fromNode && !toNode) {
|
|
214
|
+
out.notes.push("Node was removed in the target version (no later state to compare).");
|
|
215
|
+
out.change_count = 1;
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
if (!fromNode && !toNode) {
|
|
219
|
+
out.notes.push("Node not found in either version.");
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
if (fromNode.name !== toNode.name) {
|
|
223
|
+
out.name_changed = { from: fromNode.name, to: toNode.name };
|
|
224
|
+
}
|
|
225
|
+
if ((fromNode.description ?? "") !== (toNode.description ?? "")) {
|
|
226
|
+
out.description_changed = {
|
|
227
|
+
from: fromNode.description ?? "",
|
|
228
|
+
to: toNode.description ?? "",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const fromChildIds = new Set((fromNode.children ?? []).filter((c) => c?.id).map((c) => c.id));
|
|
232
|
+
const toChildIds = new Set((toNode.children ?? []).filter((c) => c?.id).map((c) => c.id));
|
|
233
|
+
for (const c of toNode.children ?? []) {
|
|
234
|
+
if (c?.id && !fromChildIds.has(c.id)) {
|
|
235
|
+
out.children_added.push({ id: c.id, name: c.name ?? "", type: c.type ?? "" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const c of fromNode.children ?? []) {
|
|
239
|
+
if (c?.id && !toChildIds.has(c.id)) {
|
|
240
|
+
out.children_removed.push({ id: c.id, name: c.name ?? "", type: c.type ?? "" });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (fromNode.type === "COMPONENT_SET" || toNode.type === "COMPONENT_SET") {
|
|
244
|
+
const propDiff = diffComponentPropertyDefinitions(fromNode.componentPropertyDefinitions, toNode.componentPropertyDefinitions);
|
|
245
|
+
const hasAny = propDiff.summary.added > 0 ||
|
|
246
|
+
propDiff.summary.removed > 0 ||
|
|
247
|
+
propDiff.summary.type_changed > 0 ||
|
|
248
|
+
propDiff.summary.default_changed > 0;
|
|
249
|
+
if (hasAny)
|
|
250
|
+
out.component_properties = propDiff;
|
|
251
|
+
}
|
|
252
|
+
out.binding_changes = collectBindingChanges(fromNode, toNode);
|
|
253
|
+
out.change_count =
|
|
254
|
+
(out.name_changed ? 1 : 0) +
|
|
255
|
+
(out.description_changed ? 1 : 0) +
|
|
256
|
+
out.children_added.length +
|
|
257
|
+
out.children_removed.length +
|
|
258
|
+
(out.component_properties
|
|
259
|
+
? out.component_properties.summary.added +
|
|
260
|
+
out.component_properties.summary.removed +
|
|
261
|
+
out.component_properties.summary.type_changed +
|
|
262
|
+
out.component_properties.summary.default_changed
|
|
263
|
+
: 0) +
|
|
264
|
+
out.binding_changes.length;
|
|
265
|
+
if (mode === "summary") {
|
|
266
|
+
// Strip detail arrays, keep counts only
|
|
267
|
+
out.children_added = [];
|
|
268
|
+
out.children_removed = [];
|
|
269
|
+
if (out.component_properties) {
|
|
270
|
+
out.component_properties = {
|
|
271
|
+
added: [],
|
|
272
|
+
removed: [],
|
|
273
|
+
type_changed: [],
|
|
274
|
+
default_changed: [],
|
|
275
|
+
summary: out.component_properties.summary,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
out.binding_changes = [];
|
|
279
|
+
}
|
|
280
|
+
else if (mode === "standard") {
|
|
281
|
+
// Keep the lightweight summaries (children_added, children_removed,
|
|
282
|
+
// component_properties.summary). Strip the heavy detail arrays.
|
|
283
|
+
if (out.component_properties) {
|
|
284
|
+
out.component_properties = {
|
|
285
|
+
added: out.component_properties.added.map((p) => ({ ...p, default_value: undefined })),
|
|
286
|
+
removed: out.component_properties.removed.map((p) => ({
|
|
287
|
+
...p,
|
|
288
|
+
default_value: undefined,
|
|
289
|
+
})),
|
|
290
|
+
type_changed: out.component_properties.type_changed,
|
|
291
|
+
default_changed: out.component_properties.default_changed.map((d) => ({
|
|
292
|
+
name: d.name,
|
|
293
|
+
from_default: undefined,
|
|
294
|
+
to_default: undefined,
|
|
295
|
+
})),
|
|
296
|
+
summary: out.component_properties.summary,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// Bindings: keep as-is, they're already compact.
|
|
300
|
+
}
|
|
301
|
+
// mode === "detailed" → keep everything
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// Helpers
|
|
306
|
+
// ============================================================================
|
|
307
|
+
function deepEqual(a, b) {
|
|
308
|
+
if (a === b)
|
|
309
|
+
return true;
|
|
310
|
+
if (a === null || b === null)
|
|
311
|
+
return false;
|
|
312
|
+
if (typeof a !== typeof b)
|
|
313
|
+
return false;
|
|
314
|
+
if (typeof a !== "object")
|
|
315
|
+
return false;
|
|
316
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
317
|
+
return false;
|
|
318
|
+
if (Array.isArray(a)) {
|
|
319
|
+
if (a.length !== b.length)
|
|
320
|
+
return false;
|
|
321
|
+
for (let i = 0; i < a.length; i++)
|
|
322
|
+
if (!deepEqual(a[i], b[i]))
|
|
323
|
+
return false;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
const aKeys = Object.keys(a);
|
|
327
|
+
const bKeys = Object.keys(b);
|
|
328
|
+
if (aKeys.length !== bKeys.length)
|
|
329
|
+
return false;
|
|
330
|
+
for (const k of aKeys)
|
|
331
|
+
if (!deepEqual(a[k], b[k]))
|
|
332
|
+
return false;
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property comparison primitives.
|
|
3
|
+
*
|
|
4
|
+
* Pure helper functions used by both the design-code parity tools and
|
|
5
|
+
* the version diff engine. Moved here from src/core/design-code-tools.ts
|
|
6
|
+
* so multiple consumers can share without circular dependencies.
|
|
7
|
+
*/
|
|
8
|
+
/** Convert Figma RGBA (0-1 floats) to hex string */
|
|
9
|
+
export function figmaRGBAToHex(color) {
|
|
10
|
+
const r = Math.round(color.r * 255);
|
|
11
|
+
const g = Math.round(color.g * 255);
|
|
12
|
+
const b = Math.round(color.b * 255);
|
|
13
|
+
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
|
|
14
|
+
if (color.a !== undefined && color.a < 1) {
|
|
15
|
+
const a = Math.round(color.a * 255);
|
|
16
|
+
return `${hex}${a.toString(16).padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
return hex;
|
|
19
|
+
}
|
|
20
|
+
/** Normalize a color string for comparison (uppercase hex without alpha if fully opaque) */
|
|
21
|
+
export function normalizeColor(color) {
|
|
22
|
+
let c = color.trim().toUpperCase();
|
|
23
|
+
// Strip alpha if fully opaque (FF)
|
|
24
|
+
if (c.length === 9 && c.endsWith("FF")) {
|
|
25
|
+
c = c.slice(0, 7);
|
|
26
|
+
}
|
|
27
|
+
// Expand shorthand (#RGB -> #RRGGBB)
|
|
28
|
+
if (/^#[0-9A-F]{3}$/.test(c)) {
|
|
29
|
+
c = `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
|
|
30
|
+
}
|
|
31
|
+
return c;
|
|
32
|
+
}
|
|
33
|
+
/** Compare numeric values with a tolerance */
|
|
34
|
+
export function numericClose(a, b, tolerance = 1) {
|
|
35
|
+
return Math.abs(a - b) <= tolerance;
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory LRU cache for Figma file snapshots at past versions.
|
|
3
|
+
*
|
|
4
|
+
* Past versions are immutable, so a cached snapshot for `${fileKey}:${versionId}`
|
|
5
|
+
* never goes stale. The cache is process-scoped (no persistence) and bounded by
|
|
6
|
+
* a max-entries cap rather than a byte budget — keeps the implementation simple
|
|
7
|
+
* and avoids assumptions about JSON serialization size.
|
|
8
|
+
*
|
|
9
|
+
* HEAD ("current") is intentionally NOT cached because it changes on every save.
|
|
10
|
+
* Callers should pass `null` as the versionId for HEAD requests; the cache
|
|
11
|
+
* methods short-circuit on null.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const cache = new VersionSnapshotCache();
|
|
15
|
+
* const key = cache.makeKey(fileKey, versionId, depth, nodeIds);
|
|
16
|
+
* let snapshot = cache.get(key);
|
|
17
|
+
* if (!snapshot) {
|
|
18
|
+
* snapshot = await api.getFile(fileKey, { version: versionId, depth });
|
|
19
|
+
* cache.set(key, snapshot);
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
export class VersionSnapshotCache {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
// Map preserves insertion order — first key is the LRU.
|
|
25
|
+
this.store = new Map();
|
|
26
|
+
this.maxEntries = options.maxEntries ?? 50;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build a stable cache key from the request parameters.
|
|
30
|
+
* Returns null for HEAD requests (versionId is null/undefined/empty).
|
|
31
|
+
*/
|
|
32
|
+
makeKey(fileKey, versionId, depth, nodeIds) {
|
|
33
|
+
if (!versionId)
|
|
34
|
+
return null;
|
|
35
|
+
const idsHash = nodeIds && nodeIds.length > 0
|
|
36
|
+
? [...nodeIds].sort().join(",")
|
|
37
|
+
: "";
|
|
38
|
+
const depthPart = depth === undefined ? "full" : String(depth);
|
|
39
|
+
return `${fileKey}:${versionId}:${depthPart}:${idsHash}`;
|
|
40
|
+
}
|
|
41
|
+
get(key) {
|
|
42
|
+
if (key === null)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (!this.store.has(key))
|
|
45
|
+
return undefined;
|
|
46
|
+
// Refresh recency: delete + re-insert moves the key to the end.
|
|
47
|
+
const value = this.store.get(key);
|
|
48
|
+
this.store.delete(key);
|
|
49
|
+
this.store.set(key, value);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
set(key, value) {
|
|
53
|
+
if (key === null)
|
|
54
|
+
return;
|
|
55
|
+
if (this.store.has(key))
|
|
56
|
+
this.store.delete(key);
|
|
57
|
+
this.store.set(key, value);
|
|
58
|
+
while (this.store.size > this.maxEntries) {
|
|
59
|
+
const oldestKey = this.store.keys().next().value;
|
|
60
|
+
if (oldestKey === undefined)
|
|
61
|
+
break;
|
|
62
|
+
this.store.delete(oldestKey);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
has(key) {
|
|
66
|
+
return key !== null && this.store.has(key);
|
|
67
|
+
}
|
|
68
|
+
get size() {
|
|
69
|
+
return this.store.size;
|
|
70
|
+
}
|
|
71
|
+
clear() {
|
|
72
|
+
this.store.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -308,6 +308,25 @@ export class FigmaAPI {
|
|
|
308
308
|
method: 'DELETE',
|
|
309
309
|
});
|
|
310
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* GET /v1/files/:file_key/versions
|
|
313
|
+
* List a file's version history. Cursor-style pagination via before/after
|
|
314
|
+
* (cursors are version IDs). Response includes pagination.prev_page and
|
|
315
|
+
* pagination.next_page as full URLs — Figma recommends following those
|
|
316
|
+
* directly rather than reconstructing cursors. Requires the
|
|
317
|
+
* `file_versions:read` OAuth scope (or PAT "Versions" Read permission).
|
|
318
|
+
*/
|
|
319
|
+
async getFileVersions(fileKey, options) {
|
|
320
|
+
const params = new URLSearchParams();
|
|
321
|
+
if (options?.page_size !== undefined)
|
|
322
|
+
params.set('page_size', String(options.page_size));
|
|
323
|
+
if (options?.before)
|
|
324
|
+
params.set('before', options.before);
|
|
325
|
+
if (options?.after)
|
|
326
|
+
params.set('after', options.after);
|
|
327
|
+
const query = params.toString() ? `?${params.toString()}` : '';
|
|
328
|
+
return this.request(`/files/${fileKey}/versions${query}`);
|
|
329
|
+
}
|
|
311
330
|
/**
|
|
312
331
|
* Helper: Get all design tokens (variables) with formatted output
|
|
313
332
|
* Both local and published can fail gracefully (e.g., 403 without Enterprise plan)
|