@mp3wizard/figma-console-mcp 1.32.2 → 1.34.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 +26 -17
- package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
- package/dist/cloudflare/core/design-code-tools.js +60 -17
- package/dist/cloudflare/core/design-system-manifest.js +19 -14
- package/dist/cloudflare/core/design-system-tools.js +43 -34
- package/dist/cloudflare/core/diagnose-tool.js +4 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
- package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
- package/dist/cloudflare/core/figma-api.js +118 -54
- package/dist/cloudflare/core/figma-tools.js +179 -63
- package/dist/cloudflare/core/port-discovery.js +404 -31
- package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
- package/dist/cloudflare/core/tokens/config.js +10 -0
- package/dist/cloudflare/core/tokens/dialect.js +232 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
- package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
- package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/cloudflare/core/tokens/index.js +2 -1
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
- package/dist/cloudflare/core/tokens/schemas.js +4 -0
- package/dist/cloudflare/core/tokens-tools.js +1017 -88
- package/dist/cloudflare/core/version-tools.js +44 -3
- package/dist/cloudflare/core/websocket-connector.js +42 -0
- package/dist/cloudflare/core/websocket-server.js +99 -8
- package/dist/cloudflare/core/write-tools.js +355 -86
- package/dist/cloudflare/index.js +7 -7
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +60 -17
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/design-system-manifest.d.ts +1 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -1
- package/dist/core/design-system-manifest.js +19 -14
- package/dist/core/design-system-manifest.js.map +1 -1
- package/dist/core/design-system-tools.d.ts.map +1 -1
- package/dist/core/design-system-tools.js +43 -34
- package/dist/core/design-system-tools.js.map +1 -1
- package/dist/core/diagnose-tool.d.ts +8 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -1
- package/dist/core/diagnose-tool.js +4 -0
- package/dist/core/diagnose-tool.js.map +1 -1
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
- package/dist/core/enrichment/enrichment-service.js +11 -5
- package/dist/core/enrichment/enrichment-service.js.map +1 -1
- package/dist/core/enrichment/style-resolver.d.ts +7 -2
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
- package/dist/core/enrichment/style-resolver.js +38 -18
- package/dist/core/enrichment/style-resolver.js.map +1 -1
- package/dist/core/figma-api.d.ts +18 -9
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +118 -54
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/figma-connector.d.ts +12 -0
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +179 -63
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/port-discovery.d.ts +40 -0
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +404 -31
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/tokens/alias-resolver.d.ts +45 -3
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
- package/dist/core/tokens/alias-resolver.js +75 -5
- package/dist/core/tokens/alias-resolver.js.map +1 -1
- package/dist/core/tokens/config.d.ts +28 -0
- package/dist/core/tokens/config.d.ts.map +1 -1
- package/dist/core/tokens/config.js +10 -0
- package/dist/core/tokens/config.js.map +1 -1
- package/dist/core/tokens/dialect.d.ts +107 -0
- package/dist/core/tokens/dialect.d.ts.map +1 -0
- package/dist/core/tokens/dialect.js +233 -0
- package/dist/core/tokens/dialect.js.map +1 -0
- package/dist/core/tokens/figma-converter.d.ts +23 -2
- package/dist/core/tokens/figma-converter.d.ts.map +1 -1
- package/dist/core/tokens/figma-converter.js +144 -16
- package/dist/core/tokens/figma-converter.js.map +1 -1
- package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
- package/dist/core/tokens/formatters/css-vars.js +21 -12
- package/dist/core/tokens/formatters/css-vars.js.map +1 -1
- package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
- package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
- package/dist/core/tokens/formatters/dtcg.js +106 -30
- package/dist/core/tokens/formatters/dtcg.js.map +1 -1
- package/dist/core/tokens/formatters/json.d.ts.map +1 -1
- package/dist/core/tokens/formatters/json.js +28 -10
- package/dist/core/tokens/formatters/json.js.map +1 -1
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
- package/dist/core/tokens/formatters/scss.js +19 -13
- package/dist/core/tokens/formatters/scss.js.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
- package/dist/core/tokens/index.d.ts +2 -1
- package/dist/core/tokens/index.d.ts.map +1 -1
- package/dist/core/tokens/index.js +2 -1
- package/dist/core/tokens/index.js.map +1 -1
- package/dist/core/tokens/parsers/dtcg.js +32 -5
- package/dist/core/tokens/parsers/dtcg.js.map +1 -1
- package/dist/core/tokens/schemas.d.ts +3 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -1
- package/dist/core/tokens/schemas.js +4 -0
- package/dist/core/tokens/schemas.js.map +1 -1
- package/dist/core/tokens/types.d.ts +57 -1
- package/dist/core/tokens/types.d.ts.map +1 -1
- package/dist/core/tokens/types.js.map +1 -1
- package/dist/core/tokens-tools.d.ts +250 -7
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +1017 -88
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/core/version-tools.d.ts.map +1 -1
- package/dist/core/version-tools.js +44 -3
- package/dist/core/version-tools.js.map +1 -1
- package/dist/core/websocket-connector.d.ts +38 -0
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-connector.js +42 -0
- package/dist/core/websocket-connector.js.map +1 -1
- package/dist/core/websocket-server.d.ts +23 -0
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +99 -8
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +355 -86
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts +0 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +253 -63
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +382 -28
- package/figma-desktop-bridge/ui.html +578 -292
- package/package.json +2 -2
|
@@ -56,29 +56,38 @@ export class StyleValueResolver {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
|
-
* Resolve a variable's value, handling alias chains
|
|
59
|
+
* Resolve a variable's value, handling alias chains.
|
|
60
|
+
*
|
|
61
|
+
* `modeId` selects which mode's value to resolve; omitted, it falls back
|
|
62
|
+
* to the variable's first mode (legacy behavior for callers that don't
|
|
63
|
+
* care about modes). The cache is keyed per (variable, mode) so
|
|
64
|
+
* multi-mode variables don't all resolve to the first mode's value.
|
|
60
65
|
*/
|
|
61
|
-
async resolveVariableValue(variable, allVariables, maxDepth = 10, currentDepth = 0) {
|
|
66
|
+
async resolveVariableValue(variable, allVariables, maxDepth = 10, currentDepth = 0, modeId) {
|
|
62
67
|
if (currentDepth >= maxDepth) {
|
|
63
68
|
this.logger.warn({
|
|
64
69
|
variable: variable.name,
|
|
65
70
|
}, "Max resolution depth reached");
|
|
66
71
|
return null;
|
|
67
72
|
}
|
|
68
|
-
|
|
73
|
+
// Pick the mode to resolve: the requested modeId when this variable
|
|
74
|
+
// has a value for it, otherwise the first available mode (alias
|
|
75
|
+
// targets in other collections have different modeIds).
|
|
76
|
+
const modes = Object.keys(variable.valuesByMode || {});
|
|
77
|
+
if (modes.length === 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const effectiveMode = modeId !== undefined && variable.valuesByMode[modeId] !== undefined
|
|
81
|
+
? modeId
|
|
82
|
+
: modes[0];
|
|
83
|
+
const cacheKey = `var:${variable.id}:${effectiveMode}`;
|
|
69
84
|
if (this.variableCache.has(cacheKey)) {
|
|
70
85
|
return this.variableCache.get(cacheKey);
|
|
71
86
|
}
|
|
72
87
|
try {
|
|
73
|
-
|
|
74
|
-
const modes = Object.keys(variable.valuesByMode || {});
|
|
75
|
-
if (modes.length === 0) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
const defaultMode = modes[0]; // TODO: Support mode selection
|
|
79
|
-
const value = variable.valuesByMode[defaultMode];
|
|
88
|
+
const value = variable.valuesByMode[effectiveMode];
|
|
80
89
|
// Check if this is an alias (reference to another variable)
|
|
81
|
-
if (typeof value === "object" && value.type === "VARIABLE_ALIAS") {
|
|
90
|
+
if (value !== null && typeof value === "object" && value.type === "VARIABLE_ALIAS") {
|
|
82
91
|
const targetVariable = allVariables.get(value.id);
|
|
83
92
|
if (!targetVariable) {
|
|
84
93
|
this.logger.warn({
|
|
@@ -87,8 +96,9 @@ export class StyleValueResolver {
|
|
|
87
96
|
}, "Variable alias target not found");
|
|
88
97
|
return null;
|
|
89
98
|
}
|
|
90
|
-
// Recursively resolve the alias
|
|
91
|
-
|
|
99
|
+
// Recursively resolve the alias, carrying the requested modeId
|
|
100
|
+
// through so same-collection targets resolve the same mode.
|
|
101
|
+
const resolvedValue = await this.resolveVariableValue(targetVariable, allVariables, maxDepth, currentDepth + 1, modeId);
|
|
92
102
|
this.variableCache.set(cacheKey, resolvedValue);
|
|
93
103
|
return resolvedValue;
|
|
94
104
|
}
|
|
@@ -109,7 +119,9 @@ export class StyleValueResolver {
|
|
|
109
119
|
* Format a variable value based on its type
|
|
110
120
|
*/
|
|
111
121
|
formatVariableValue(value, type) {
|
|
112
|
-
|
|
122
|
+
// Guard only null/undefined — 0, false, and "" are legitimate
|
|
123
|
+
// variable values (opacity 0, boolean flags off, empty strings).
|
|
124
|
+
if (value === null || value === undefined)
|
|
113
125
|
return null;
|
|
114
126
|
switch (type) {
|
|
115
127
|
case "COLOR":
|
|
@@ -133,10 +145,18 @@ export class StyleValueResolver {
|
|
|
133
145
|
return color;
|
|
134
146
|
}
|
|
135
147
|
if (color.r !== undefined && color.g !== undefined && color.b !== undefined) {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
148
|
+
const clampByte = (f) => Math.max(0, Math.min(255, Math.round(f * 255)));
|
|
149
|
+
const toHex = (byte) => byte.toString(16).padStart(2, "0");
|
|
150
|
+
const r = clampByte(color.r);
|
|
151
|
+
const g = clampByte(color.g);
|
|
152
|
+
const b = clampByte(color.b);
|
|
153
|
+
let hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
154
|
+
// Preserve alpha: append the alpha byte for semi-transparent colors
|
|
155
|
+
// (mirrors rgbaToHex in tokens/figma-converter.ts).
|
|
156
|
+
if (color.a !== undefined && color.a < 1) {
|
|
157
|
+
hex += toHex(clampByte(color.a));
|
|
158
|
+
}
|
|
159
|
+
return hex.toUpperCase();
|
|
140
160
|
}
|
|
141
161
|
return null;
|
|
142
162
|
}
|
|
@@ -7,13 +7,23 @@ const logger = createChildLogger({ component: 'figma-api' });
|
|
|
7
7
|
const FIGMA_API_BASE = 'https://api.figma.com/v1';
|
|
8
8
|
/**
|
|
9
9
|
* Extract file key from Figma URL
|
|
10
|
+
* Branch-aware: for branch URLs (/design/KEY/branch/BRANCHKEY/Name) returns the
|
|
11
|
+
* BRANCH key — the branch is its own file in the REST API, and returning the
|
|
12
|
+
* main key would silently target the wrong file (comments/diffs/file-data).
|
|
10
13
|
* @example https://www.figma.com/design/abc123/My-File -> abc123
|
|
14
|
+
* @example https://www.figma.com/design/abc123/branch/xyz789/My-File -> xyz789
|
|
11
15
|
*/
|
|
12
16
|
export function extractFileKey(url) {
|
|
13
17
|
try {
|
|
14
18
|
const urlObj = new URL(url);
|
|
15
|
-
//
|
|
16
|
-
|
|
19
|
+
// Branch URLs: /design/FILE_KEY/branch/BRANCH_KEY — the branch key is the
|
|
20
|
+
// effective file key for all REST API calls.
|
|
21
|
+
const branchMatch = urlObj.pathname.match(/\/(design|file|board|slides)\/[a-zA-Z0-9]+\/branch\/([a-zA-Z0-9]+)/);
|
|
22
|
+
if (branchMatch) {
|
|
23
|
+
return branchMatch[2];
|
|
24
|
+
}
|
|
25
|
+
// Match patterns like /design/FILE_KEY, /file/FILE_KEY, /board/FILE_KEY (FigJam), /slides/FILE_KEY
|
|
26
|
+
const match = urlObj.pathname.match(/\/(design|file|board|slides)\/([a-zA-Z0-9]+)/);
|
|
17
27
|
return match ? match[2] : null;
|
|
18
28
|
}
|
|
19
29
|
catch (error) {
|
|
@@ -21,6 +31,15 @@ export function extractFileKey(url) {
|
|
|
21
31
|
return null;
|
|
22
32
|
}
|
|
23
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a node ID to Figma's canonical colon form.
|
|
36
|
+
* URLs use dashes ("695-313") but the REST API keys response maps by colon
|
|
37
|
+
* form ("695:313") — indexing a response with a dashed ID silently returns
|
|
38
|
+
* undefined, which reads as "node may not exist".
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeNodeId(nodeId) {
|
|
41
|
+
return nodeId.replace(/-/g, ':');
|
|
42
|
+
}
|
|
24
43
|
/**
|
|
25
44
|
* Extract comprehensive URL info including branch and node IDs
|
|
26
45
|
* Supports both URL formats:
|
|
@@ -36,7 +55,7 @@ export function extractFigmaUrlInfo(url) {
|
|
|
36
55
|
try {
|
|
37
56
|
const urlObj = new URL(url);
|
|
38
57
|
// First try: Path-based branch format /design/{fileKey}/branch/{branchKey}/{fileName}
|
|
39
|
-
const branchPathMatch = urlObj.pathname.match(/\/(design|file)\/([a-zA-Z0-9]+)\/branch\/([a-zA-Z0-9]+)/);
|
|
58
|
+
const branchPathMatch = urlObj.pathname.match(/\/(design|file|board|slides)\/([a-zA-Z0-9]+)\/branch\/([a-zA-Z0-9]+)/);
|
|
40
59
|
if (branchPathMatch) {
|
|
41
60
|
const fileKey = branchPathMatch[2];
|
|
42
61
|
const branchId = branchPathMatch[3];
|
|
@@ -45,7 +64,7 @@ export function extractFigmaUrlInfo(url) {
|
|
|
45
64
|
return { fileKey, branchId, nodeId };
|
|
46
65
|
}
|
|
47
66
|
// Second try: Standard format /design/{fileKey}/{fileName} with optional ?branch-id=
|
|
48
|
-
const standardMatch = urlObj.pathname.match(/\/(design|file)\/([a-zA-Z0-9]+)/);
|
|
67
|
+
const standardMatch = urlObj.pathname.match(/\/(design|file|board|slides)\/([a-zA-Z0-9]+)/);
|
|
49
68
|
if (!standardMatch)
|
|
50
69
|
return null;
|
|
51
70
|
const fileKey = standardMatch[2];
|
|
@@ -109,14 +128,54 @@ export class FigmaAPI {
|
|
|
109
128
|
else {
|
|
110
129
|
headers['X-Figma-Token'] = this.accessToken;
|
|
111
130
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
// Up to 3 total attempts on 429 (rate limit), honoring Retry-After when
|
|
132
|
+
// present (seconds), else exponential backoff (1s, 2s). Delays here run
|
|
133
|
+
// inside the request promise — callers wrapping with withTimeout() still
|
|
134
|
+
// get their race-based rejection if the overall wait exceeds their budget.
|
|
135
|
+
const MAX_ATTEMPTS = 3;
|
|
136
|
+
let response;
|
|
137
|
+
for (let attempt = 1;; attempt++) {
|
|
138
|
+
response = await fetch(url, {
|
|
139
|
+
...options,
|
|
140
|
+
headers,
|
|
141
|
+
});
|
|
142
|
+
if (response.status !== 429 || attempt >= MAX_ATTEMPTS) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
const retryAfterHeader = response.headers.get('retry-after');
|
|
146
|
+
const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
|
147
|
+
// Cap Retry-After waits at 30s so a pathological header can't hang callers.
|
|
148
|
+
const delayMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
149
|
+
? Math.min(retryAfterSeconds, 30) * 1000
|
|
150
|
+
: attempt * 1000; // 1s after attempt 1, 2s after attempt 2
|
|
151
|
+
logger.warn({ url, attempt, delayMs, retryAfterHeader }, 'Figma API rate limited (429), retrying');
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
153
|
+
}
|
|
116
154
|
if (!response.ok) {
|
|
117
155
|
const errorText = await response.text();
|
|
118
156
|
logger.error({ status: response.status, statusText: response.statusText, body: errorText }, 'Figma API request failed');
|
|
119
|
-
|
|
157
|
+
// The `Figma API error (<status>):` prefix is LOAD-BEARING — downstream
|
|
158
|
+
// tool handlers branch on errorMessage.includes("403") etc. Keep it
|
|
159
|
+
// verbatim at the start of the message; only APPEND guidance after it.
|
|
160
|
+
let message = `Figma API error (${response.status}): ${errorText}`;
|
|
161
|
+
// Token-auth failures (expired/invalid token) get actionable guidance.
|
|
162
|
+
// Plan-gate 403s (e.g. Variables API on non-Enterprise) do NOT match the
|
|
163
|
+
// token pattern and must not be flagged as auth errors.
|
|
164
|
+
const isAuthError = (response.status === 401 || response.status === 403) &&
|
|
165
|
+
/invalid token|token expired|unauthorized/i.test(errorText);
|
|
166
|
+
if (isAuthError) {
|
|
167
|
+
message +=
|
|
168
|
+
' — Your Figma access token is expired or invalid. Generate a new personal access token at figma.com → Settings → Security → Personal access tokens, then update FIGMA_ACCESS_TOKEN in your MCP config. If Figma Desktop is open with the Desktop Bridge plugin running, most tools work without any REST token.';
|
|
169
|
+
}
|
|
170
|
+
else if (response.status === 429) {
|
|
171
|
+
message += ' Rate limited by Figma — wait a moment and retry.';
|
|
172
|
+
}
|
|
173
|
+
const err = new Error(message);
|
|
174
|
+
err.status = response.status;
|
|
175
|
+
if (isAuthError) {
|
|
176
|
+
err.isAuthError = true;
|
|
177
|
+
}
|
|
178
|
+
throw err;
|
|
120
179
|
}
|
|
121
180
|
const data = await response.json();
|
|
122
181
|
return data;
|
|
@@ -145,43 +204,6 @@ export class FigmaAPI {
|
|
|
145
204
|
}
|
|
146
205
|
return this.request(endpoint);
|
|
147
206
|
}
|
|
148
|
-
/**
|
|
149
|
-
* Resolve a branch key from a branch ID
|
|
150
|
-
* If branchId is provided, fetches branch data and returns the branch's unique key
|
|
151
|
-
* Otherwise returns the main file key unchanged
|
|
152
|
-
* @param fileKey The main file key from the URL
|
|
153
|
-
* @param branchId Optional branch ID from URL query param (branch-id)
|
|
154
|
-
* @returns The effective file key to use for API calls (branch key if on branch, otherwise fileKey)
|
|
155
|
-
*/
|
|
156
|
-
async getBranchKey(fileKey, branchId) {
|
|
157
|
-
if (!branchId) {
|
|
158
|
-
return fileKey;
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
logger.info({ fileKey, branchId }, 'Resolving branch key');
|
|
162
|
-
const fileData = await this.getFile(fileKey, { branch_data: true });
|
|
163
|
-
const branches = fileData.branches || [];
|
|
164
|
-
// Try to find branch by key (branchId might already be the key)
|
|
165
|
-
// or by matching other identifiers
|
|
166
|
-
const branch = branches.find((b) => b.key === branchId || b.name === branchId);
|
|
167
|
-
if (branch?.key) {
|
|
168
|
-
logger.info({ fileKey, branchId, branchKey: branch.key, branchName: branch.name }, 'Resolved branch key');
|
|
169
|
-
return branch.key;
|
|
170
|
-
}
|
|
171
|
-
// If branchId looks like a file key (alphanumeric), it might already be the branch key
|
|
172
|
-
// In this case, return it directly as it may be usable
|
|
173
|
-
if (/^[a-zA-Z0-9]+$/.test(branchId)) {
|
|
174
|
-
logger.info({ fileKey, branchId }, 'Branch ID appears to be a key, using directly');
|
|
175
|
-
return branchId;
|
|
176
|
-
}
|
|
177
|
-
logger.warn({ fileKey, branchId, availableBranches: branches.map((b) => ({ key: b.key, name: b.name })) }, 'Branch not found in file, using main file key');
|
|
178
|
-
return fileKey;
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
logger.error({ error, fileKey, branchId }, 'Failed to resolve branch key, using main file key');
|
|
182
|
-
return fileKey;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
207
|
/**
|
|
186
208
|
* GET /v1/files/:file_key/variables/local
|
|
187
209
|
* Get local variables (design tokens) from a file
|
|
@@ -205,11 +227,17 @@ export class FigmaAPI {
|
|
|
205
227
|
/**
|
|
206
228
|
* GET /v1/files/:file_key/nodes
|
|
207
229
|
* Get specific nodes by ID
|
|
230
|
+
*
|
|
231
|
+
* Node IDs are normalized to Figma's colon form ("695-313" -> "695:313")
|
|
232
|
+
* before the request, and the response map is aliased back under any
|
|
233
|
+
* dashed IDs the caller passed — Figma keys the response by colon-format
|
|
234
|
+
* IDs, so without the alias a dashed lookup silently returns undefined.
|
|
208
235
|
*/
|
|
209
236
|
async getNodes(fileKey, nodeIds, options) {
|
|
210
237
|
let endpoint = `/files/${fileKey}/nodes`;
|
|
238
|
+
const normalizedIds = nodeIds.map((id) => normalizeNodeId(id));
|
|
211
239
|
const params = new URLSearchParams();
|
|
212
|
-
params.append('ids',
|
|
240
|
+
params.append('ids', normalizedIds.join(','));
|
|
213
241
|
if (options?.version)
|
|
214
242
|
params.append('version', options.version);
|
|
215
243
|
if (options?.depth !== undefined)
|
|
@@ -219,7 +247,19 @@ export class FigmaAPI {
|
|
|
219
247
|
if (options?.plugin_data)
|
|
220
248
|
params.append('plugin_data', options.plugin_data);
|
|
221
249
|
endpoint += `?${params.toString()}`;
|
|
222
|
-
|
|
250
|
+
const response = await this.request(endpoint);
|
|
251
|
+
// Defensive aliasing: let callers index response.nodes by the ID form
|
|
252
|
+
// they originally passed (dashed), not just Figma's colon form.
|
|
253
|
+
if (response?.nodes) {
|
|
254
|
+
for (let i = 0; i < nodeIds.length; i++) {
|
|
255
|
+
const original = nodeIds[i];
|
|
256
|
+
const normalized = normalizedIds[i];
|
|
257
|
+
if (original !== normalized && response.nodes[normalized] !== undefined && response.nodes[original] === undefined) {
|
|
258
|
+
response.nodes[original] = response.nodes[normalized];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return response;
|
|
223
263
|
}
|
|
224
264
|
/**
|
|
225
265
|
* GET /v1/files/:file_key/styles
|
|
@@ -271,8 +311,12 @@ export class FigmaAPI {
|
|
|
271
311
|
*/
|
|
272
312
|
async getImages(fileKey, nodeIds, options) {
|
|
273
313
|
const params = new URLSearchParams();
|
|
274
|
-
// Handle single or multiple node IDs
|
|
275
|
-
|
|
314
|
+
// Handle single or multiple node IDs. Normalize to Figma's colon form
|
|
315
|
+
// ("695-313" -> "695:313") — the response map is keyed by colon IDs, so
|
|
316
|
+
// a dashed request ID would otherwise never match its own result.
|
|
317
|
+
const originalIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
|
|
318
|
+
const normalizedIds = originalIds.map((id) => normalizeNodeId(id));
|
|
319
|
+
const ids = normalizedIds.join(',');
|
|
276
320
|
params.append('ids', ids);
|
|
277
321
|
// Add optional parameters
|
|
278
322
|
if (options?.scale !== undefined)
|
|
@@ -291,7 +335,19 @@ export class FigmaAPI {
|
|
|
291
335
|
params.append('contents_only', options.contents_only.toString());
|
|
292
336
|
const endpoint = `/images/${fileKey}?${params.toString()}`;
|
|
293
337
|
logger.info({ fileKey, ids, options }, 'Rendering images');
|
|
294
|
-
|
|
338
|
+
const response = await this.request(endpoint);
|
|
339
|
+
// Defensive aliasing: callers may index the images map by the dashed
|
|
340
|
+
// ID they passed. Figma keys it by colon-format ID — alias both forms.
|
|
341
|
+
if (response?.images) {
|
|
342
|
+
for (let i = 0; i < originalIds.length; i++) {
|
|
343
|
+
const original = originalIds[i];
|
|
344
|
+
const normalized = normalizedIds[i];
|
|
345
|
+
if (original !== normalized && response.images[normalized] !== undefined && response.images[original] === undefined) {
|
|
346
|
+
response.images[original] = response.images[normalized];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return response;
|
|
295
351
|
}
|
|
296
352
|
/**
|
|
297
353
|
* GET /v1/files/:file_key/comments
|
|
@@ -356,15 +412,20 @@ export class FigmaAPI {
|
|
|
356
412
|
const [localResult, publishedResult] = await Promise.all([
|
|
357
413
|
this.getLocalVariables(fileKey).catch((err) => {
|
|
358
414
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
415
|
+
logger.warn({ fileKey, error: errorMsg }, 'getLocalVariables failed; returning empty fallback');
|
|
359
416
|
return { error: errorMsg, variables: {}, variableCollections: {} };
|
|
360
417
|
}),
|
|
361
418
|
this.getPublishedVariables(fileKey).catch((err) => {
|
|
362
419
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
420
|
+
logger.warn({ fileKey, error: errorMsg }, 'getPublishedVariables failed; returning empty fallback');
|
|
363
421
|
return { error: errorMsg, variables: {} };
|
|
364
422
|
}),
|
|
365
423
|
]);
|
|
424
|
+
// Fallback shape must match the success path (already-unwrapped meta):
|
|
425
|
+
// { variables, variableCollections } — NOT { meta: {...} } — so consumers
|
|
426
|
+
// like formatVariables() see a consistent shape either way.
|
|
366
427
|
return {
|
|
367
|
-
local: 'error' in localResult ? {
|
|
428
|
+
local: 'error' in localResult ? { variables: {}, variableCollections: {} } : localResult,
|
|
368
429
|
published: 'error' in publishedResult ? { variables: {} } : publishedResult,
|
|
369
430
|
...(('error' in localResult) && { localError: localResult.error }),
|
|
370
431
|
...(('error' in publishedResult) && { publishedError: publishedResult.error }),
|
|
@@ -372,10 +433,13 @@ export class FigmaAPI {
|
|
|
372
433
|
}
|
|
373
434
|
/**
|
|
374
435
|
* Helper: Get component metadata with properties
|
|
436
|
+
* Normalizes dashed node IDs ("695-313") to colon form ("695:313") so the
|
|
437
|
+
* response-map lookup matches Figma's colon-keyed response.
|
|
375
438
|
*/
|
|
376
439
|
async getComponentData(fileKey, nodeId, depth = 4) {
|
|
377
|
-
const
|
|
378
|
-
|
|
440
|
+
const normalizedId = normalizeNodeId(nodeId);
|
|
441
|
+
const response = await this.getNodes(fileKey, [normalizedId], { depth });
|
|
442
|
+
return response.nodes?.[normalizedId] ?? response.nodes?.[nodeId];
|
|
379
443
|
}
|
|
380
444
|
/**
|
|
381
445
|
* Helper: Search for components by name
|