@animaapp/anima-sdk 0.1.0
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/.turbo/turbo-build.log +20 -0
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +270 -0
- package/dist/index.js +3988 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/anima.ts +240 -0
- package/src/codegenToAnimaFiles.ts +13 -0
- package/src/dataStream.ts +108 -0
- package/src/errors.ts +21 -0
- package/src/figma/figmaError.ts +115 -0
- package/src/figma/index.ts +2 -0
- package/src/figma/utils.ts +73 -0
- package/src/index.ts +8 -0
- package/src/settings.ts +56 -0
- package/src/types.ts +77 -0
- package/src/utils.ts +53 -0
- package/vite.config.ts +24 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@animaapp/anima-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Anima's JavaScript utilities library",
|
|
6
|
+
"author": "Anima App, Inc.",
|
|
7
|
+
"license": "ISC",
|
|
8
|
+
"packageManager": "yarn@4.6.0",
|
|
9
|
+
"exports": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
},
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/AnimaApp/anima-sdk.git"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "vite build",
|
|
24
|
+
"tests": "vitest run --dir ./tests",
|
|
25
|
+
"prepack": "yarn build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@animaapp/http-client-figma": "^1.0.2",
|
|
29
|
+
"@figma/rest-api-spec": "^0.23.0",
|
|
30
|
+
"zod": "^3.24.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"vite": "^6.0.11",
|
|
34
|
+
"vite-plugin-dts": "^4.5.0",
|
|
35
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
36
|
+
"vitest": "^3.0.5"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/anima.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { CodegenError } from "./errors";
|
|
2
|
+
import { validateSettings } from "./settings";
|
|
3
|
+
import {
|
|
4
|
+
AnimaFiles,
|
|
5
|
+
AnimaSDKResult,
|
|
6
|
+
GetCodeHandler,
|
|
7
|
+
GetCodeParams,
|
|
8
|
+
SSECodgenMessage,
|
|
9
|
+
} from "./types";
|
|
10
|
+
import { convertCodegenFilesToAnimaFiles } from "./codegenToAnimaFiles";
|
|
11
|
+
export type Auth =
|
|
12
|
+
| { token: string; teamId: string } // for Anima user, it's mandatory to have an associated team
|
|
13
|
+
| { token: string; userId?: string }; // for users from a 3rd-party client (e.g., Bolt) they may have optionally a user id
|
|
14
|
+
|
|
15
|
+
export class Anima {
|
|
16
|
+
#auth?: Auth;
|
|
17
|
+
#apiBaseAddress: string;
|
|
18
|
+
|
|
19
|
+
constructor({
|
|
20
|
+
auth,
|
|
21
|
+
apiBaseAddress = "https://public-api.animaapp.com",
|
|
22
|
+
}: {
|
|
23
|
+
auth?: Auth;
|
|
24
|
+
apiBaseAddress?: string;
|
|
25
|
+
path?: string;
|
|
26
|
+
} = {}) {
|
|
27
|
+
this.#apiBaseAddress = apiBaseAddress;
|
|
28
|
+
|
|
29
|
+
if (auth) {
|
|
30
|
+
this.auth = auth;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected hasAuth() {
|
|
35
|
+
return !!this.#auth;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set auth(auth: Auth) {
|
|
39
|
+
this.#auth = auth;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected get headers() {
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (this.#auth) {
|
|
48
|
+
headers["Authorization"] = `Bearer ${this.#auth.token}`;
|
|
49
|
+
|
|
50
|
+
if ("teamId" in this.#auth) {
|
|
51
|
+
headers["X-Team-Id"] = this.#auth.teamId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if ("userId" in this.#auth && this.#auth.userId) {
|
|
55
|
+
headers["X-User-Id"] = this.#auth.userId;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return headers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async generateCode(params: GetCodeParams, handler: GetCodeHandler = {}) {
|
|
63
|
+
if (this.hasAuth() === false) {
|
|
64
|
+
throw new Error('It needs to set "auth" before calling this method.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result: Partial<AnimaSDKResult> = {};
|
|
68
|
+
const settings = validateSettings(params.settings);
|
|
69
|
+
|
|
70
|
+
const response = await fetch(`${this.#apiBaseAddress}/v1/codegen`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
...this.headers,
|
|
74
|
+
Accept: "text/event-stream",
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
fileKey: params.fileKey,
|
|
78
|
+
figmaToken: params.figmaToken,
|
|
79
|
+
nodesId: params.nodesId,
|
|
80
|
+
language: settings.language,
|
|
81
|
+
framework: settings.framework,
|
|
82
|
+
styling: settings.styling,
|
|
83
|
+
uiLibrary: settings.uiLibrary,
|
|
84
|
+
enableTranslation: settings.enableTranslation,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
let errorMessage;
|
|
90
|
+
try {
|
|
91
|
+
const errorData = await response.json();
|
|
92
|
+
errorMessage =
|
|
93
|
+
errorData.message || `HTTP error! status: ${response.status}`;
|
|
94
|
+
} catch {
|
|
95
|
+
errorMessage = `HTTP error! status: ${response.status}`;
|
|
96
|
+
}
|
|
97
|
+
throw new CodegenError({
|
|
98
|
+
name: "HTTP Error",
|
|
99
|
+
reason: errorMessage,
|
|
100
|
+
status: response.status,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!response.body) {
|
|
105
|
+
throw new CodegenError({
|
|
106
|
+
name: "Stream Error",
|
|
107
|
+
reason: "Response body is null",
|
|
108
|
+
status: response.status,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const reader = response.body.getReader();
|
|
113
|
+
const decoder = new TextDecoder();
|
|
114
|
+
let buffer = "";
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
while (true) {
|
|
118
|
+
const { done, value } = await reader.read();
|
|
119
|
+
if (done) {
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
buffer += decoder.decode(value, { stream: true });
|
|
124
|
+
|
|
125
|
+
const lines = buffer.split("\n");
|
|
126
|
+
|
|
127
|
+
// Process all complete lines
|
|
128
|
+
buffer = lines.pop() || ""; // Keep the last incomplete line in the buffer
|
|
129
|
+
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
if (!line.trim() || line.startsWith(":")) continue;
|
|
132
|
+
|
|
133
|
+
if (line.startsWith("data: ")) {
|
|
134
|
+
let data: SSECodgenMessage;
|
|
135
|
+
try {
|
|
136
|
+
data = JSON.parse(line.slice(6));
|
|
137
|
+
} catch {
|
|
138
|
+
// ignore malformed JSON
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
switch (data.type) {
|
|
143
|
+
case "start": {
|
|
144
|
+
result.sessionId = data.sessionId;
|
|
145
|
+
typeof handler === "function"
|
|
146
|
+
? handler(data)
|
|
147
|
+
: handler.onStart?.({ sessionId: data.sessionId });
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "pre_codegen": {
|
|
152
|
+
typeof handler === "function"
|
|
153
|
+
? handler(data)
|
|
154
|
+
: handler.onPreCodegen?.({ message: data.message });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case "assets_uploaded": {
|
|
159
|
+
typeof handler === "function"
|
|
160
|
+
? handler(data)
|
|
161
|
+
: handler.onAssetsUploaded?.();
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case "figma_metadata": {
|
|
166
|
+
result.figmaFileName = data.figmaFileName;
|
|
167
|
+
result.figmaSelectedFrameName = data.figmaSelectedFrameName;
|
|
168
|
+
|
|
169
|
+
typeof handler === "function"
|
|
170
|
+
? handler(data)
|
|
171
|
+
: handler.onFigmaMetadata?.({
|
|
172
|
+
figmaFileName: data.figmaFileName,
|
|
173
|
+
figmaSelectedFrameName: data.figmaSelectedFrameName,
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "generating_code": {
|
|
179
|
+
const codegenFiles = data.payload.files as Record<
|
|
180
|
+
string,
|
|
181
|
+
{ code: string; type: "code" }
|
|
182
|
+
>;
|
|
183
|
+
|
|
184
|
+
const files = convertCodegenFilesToAnimaFiles(codegenFiles);
|
|
185
|
+
|
|
186
|
+
if (data.payload.status === "success") {
|
|
187
|
+
result.files = files;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
typeof handler === "function"
|
|
191
|
+
? handler(data)
|
|
192
|
+
: handler.onGeneratingCode?.({
|
|
193
|
+
status: data.payload.status,
|
|
194
|
+
progress: data.payload.progress,
|
|
195
|
+
files,
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case "codegen_completed": {
|
|
201
|
+
typeof handler === "function"
|
|
202
|
+
? handler(data)
|
|
203
|
+
: handler.onCodegenCompleted?.();
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case "error": {
|
|
208
|
+
// not sure if we want to throw on "stream" errors
|
|
209
|
+
throw new CodegenError({
|
|
210
|
+
name: data.payload.errorName,
|
|
211
|
+
reason: data.payload.reason,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "done": {
|
|
216
|
+
if (!result.files) {
|
|
217
|
+
// not sure if we want to throw on "logical" errors
|
|
218
|
+
// I think we should throw only on "HTTP" errors
|
|
219
|
+
throw new CodegenError({
|
|
220
|
+
name: "Invalid response",
|
|
221
|
+
reason: "No files found",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return result as AnimaSDKResult;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
reader.cancel();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new CodegenError({
|
|
235
|
+
name: "Connection",
|
|
236
|
+
reason: "Connection closed before the 'done' message",
|
|
237
|
+
status: 500,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AnimaFiles } from "./types";
|
|
2
|
+
|
|
3
|
+
export const convertCodegenFilesToAnimaFiles = (
|
|
4
|
+
codegenFiles: Record<string, { code: string; type: "code" }>
|
|
5
|
+
): AnimaFiles => {
|
|
6
|
+
return Object.entries(codegenFiles).reduce(
|
|
7
|
+
(acc, [fileName, file]) => {
|
|
8
|
+
acc[fileName] = { content: file.code, isBinary: false };
|
|
9
|
+
return acc;
|
|
10
|
+
},
|
|
11
|
+
{} as AnimaFiles
|
|
12
|
+
);
|
|
13
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Anima } from "./anima";
|
|
2
|
+
import type { CodegenErrorReason } from "./errors";
|
|
3
|
+
import type { GetCodeParams, SSECodgenMessage } from "./types";
|
|
4
|
+
|
|
5
|
+
export type StreamCodgenMessage =
|
|
6
|
+
| Exclude<SSECodgenMessage, { type: "error" }>
|
|
7
|
+
| { type: "error"; payload: { message: CodegenErrorReason, status?: number } };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Start the code generation and creates a ReadableStream to output its result.
|
|
11
|
+
*
|
|
12
|
+
* The stream is closed when the codegen ends.
|
|
13
|
+
*
|
|
14
|
+
* @param {Anima} anima - An Anima service instance to generate the code from.
|
|
15
|
+
* @param {GetCodeParams} params - Parameters required for the code generation process.
|
|
16
|
+
* @returns {ReadableStream<StreamCodgenMessage>} - A ReadableStream that emits messages related to the code generation process.
|
|
17
|
+
*/
|
|
18
|
+
export const createCodegenStream = (
|
|
19
|
+
anima: Anima,
|
|
20
|
+
params: GetCodeParams
|
|
21
|
+
): ReadableStream<StreamCodgenMessage> => {
|
|
22
|
+
return new ReadableStream({
|
|
23
|
+
start(controller) {
|
|
24
|
+
anima
|
|
25
|
+
.generateCode(params, (message) => {
|
|
26
|
+
if (message.type === "error") {
|
|
27
|
+
console.log('NOT SURE IF THIS IS REACHABLE, ALL ERRORS ARE THROWING');
|
|
28
|
+
controller.enqueue({
|
|
29
|
+
type: "error",
|
|
30
|
+
payload: { message: message.payload.reason },
|
|
31
|
+
});
|
|
32
|
+
} else {
|
|
33
|
+
controller.enqueue(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (message.type === "aborted" || message.type === "error") {
|
|
37
|
+
controller.close();
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
.then((result) => {
|
|
41
|
+
controller.enqueue({ type: "done" });
|
|
42
|
+
controller.close();
|
|
43
|
+
})
|
|
44
|
+
.catch((error) => {
|
|
45
|
+
controller.enqueue({
|
|
46
|
+
type: "error",
|
|
47
|
+
payload: {
|
|
48
|
+
message: "message" in error ? error.message : "Unknown",
|
|
49
|
+
status: "status" in error ? error.status : undefined,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
controller.close();
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates a Server-Sent Events (SSE) `Response` that forwards all messages from the code generation stream.
|
|
60
|
+
*
|
|
61
|
+
* But, if the first message indicates an error (e.g., connection failed), the function returns a 500 response with the error message.
|
|
62
|
+
*
|
|
63
|
+
* @param {Anima} anima - The Anima instance to use for creating the data stream.
|
|
64
|
+
* @param {GetCodeParams} params - The parameters for the code generation request.
|
|
65
|
+
* @returns {Promise<Response>} - A promise that resolves to an HTTP response.
|
|
66
|
+
*/
|
|
67
|
+
export const createCodegenResponseEventStream = async (
|
|
68
|
+
anima: Anima,
|
|
69
|
+
params: GetCodeParams
|
|
70
|
+
): Promise<Response> => {
|
|
71
|
+
const stream = createCodegenStream(anima, params);
|
|
72
|
+
|
|
73
|
+
const [verifyStream, consumerStream] = stream.tee();
|
|
74
|
+
const firstMessage = await verifyStream.getReader().read();
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
firstMessage.done ||
|
|
78
|
+
!firstMessage.value ||
|
|
79
|
+
firstMessage.value?.type === "error" && firstMessage.value?.payload?.status
|
|
80
|
+
) {
|
|
81
|
+
return new Response(JSON.stringify(firstMessage.value), {
|
|
82
|
+
status: firstMessage.value?.type === "error" ? (firstMessage.value?.payload?.status ?? 500) : 500,
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const seeStream = consumerStream.pipeThrough(
|
|
90
|
+
new TransformStream({
|
|
91
|
+
transform(chunk, controller) {
|
|
92
|
+
const sseString = `event: ${chunk.type}\ndata: ${JSON.stringify(
|
|
93
|
+
chunk
|
|
94
|
+
)}\n\n`;
|
|
95
|
+
controller.enqueue(sseString);
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return new Response(seeStream, {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
104
|
+
"Connection": "keep-alive",
|
|
105
|
+
"Cache-Control": "no-cache",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// TODO: `CodegenErrorReason` should be imported from `anima-public-api`
|
|
2
|
+
export type CodegenErrorReason =
|
|
3
|
+
| "The selected node is not a frame"
|
|
4
|
+
| "There is no node with the given id"
|
|
5
|
+
| "Invalid Figma token"
|
|
6
|
+
| "Figma token expired"
|
|
7
|
+
| "No files found"
|
|
8
|
+
| "Connection closed before the 'done' message"
|
|
9
|
+
| "Unknown"
|
|
10
|
+
| "Response body is null";
|
|
11
|
+
|
|
12
|
+
export class CodegenError extends Error {
|
|
13
|
+
status?: number;
|
|
14
|
+
|
|
15
|
+
constructor({ name, reason, status }: { name: string; reason: CodegenErrorReason; status?: number }) {
|
|
16
|
+
super();
|
|
17
|
+
this.name = `[Codegen Error] ${name}`;
|
|
18
|
+
this.message = reason;
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CodegenErrorReason } from "../errors";
|
|
2
|
+
|
|
3
|
+
const figmaTokenIssueErrorMessage = "Figma Token Issue";
|
|
4
|
+
export class FigmaTokenIssue extends Error {
|
|
5
|
+
fileKey: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
|
|
8
|
+
constructor({ fileKey, reason }: { fileKey: string; reason: string }) {
|
|
9
|
+
super(figmaTokenIssueErrorMessage);
|
|
10
|
+
|
|
11
|
+
this.fileKey = fileKey;
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rateLimitExceededErrorMessage = "Rate Limit Exceeded";
|
|
17
|
+
export class RateLimitExceeded extends Error {
|
|
18
|
+
fileKey: string;
|
|
19
|
+
|
|
20
|
+
constructor({ fileKey }: { fileKey: string }) {
|
|
21
|
+
super(rateLimitExceededErrorMessage);
|
|
22
|
+
|
|
23
|
+
this.fileKey = fileKey;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Not Found
|
|
28
|
+
const notFoundErrorMessage = "Not Found";
|
|
29
|
+
export class NotFound extends Error {
|
|
30
|
+
fileKey: string;
|
|
31
|
+
|
|
32
|
+
constructor({ fileKey }: { fileKey: string }) {
|
|
33
|
+
super(notFoundErrorMessage);
|
|
34
|
+
|
|
35
|
+
this.fileKey = fileKey;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export const isNotFound = (error: Error) => {
|
|
39
|
+
return error.message === notFoundErrorMessage;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Unknown Exception
|
|
43
|
+
const unknownFigmaApiExceptionMessage = "Unknown Figma API Exception";
|
|
44
|
+
export class UnknownFigmaApiException extends Error {
|
|
45
|
+
fileKey: string;
|
|
46
|
+
|
|
47
|
+
constructor({ fileKey, cause }: { fileKey: string; cause: unknown }) {
|
|
48
|
+
super(unknownFigmaApiExceptionMessage);
|
|
49
|
+
|
|
50
|
+
this.name = "UnknownFigmaApiException";
|
|
51
|
+
this.fileKey = fileKey;
|
|
52
|
+
this.cause = cause;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const isUnknownFigmaApiException = (error: Error) => {
|
|
56
|
+
return error.message === unknownFigmaApiExceptionMessage;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const isRateLimitExceeded = (error: Error) => {
|
|
60
|
+
return error.message === rateLimitExceededErrorMessage;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const isFigmaTokenIssue = (error: Error) => {
|
|
64
|
+
const figmaTokenCodegenErrors: CodegenErrorReason[] = [
|
|
65
|
+
"Invalid Figma token",
|
|
66
|
+
"Figma token expired",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
return [figmaTokenIssueErrorMessage, ...figmaTokenCodegenErrors].includes(
|
|
70
|
+
error.message
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const handleFigmaApiError = (error: any, fileKey: string) => {
|
|
75
|
+
const err = error?.cause?.body || error.body;
|
|
76
|
+
|
|
77
|
+
if (err?.status === 403) {
|
|
78
|
+
throw new FigmaTokenIssue({
|
|
79
|
+
fileKey,
|
|
80
|
+
reason: error?.cause?.body || error.body,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (err?.status === 429) {
|
|
85
|
+
throw new RateLimitExceeded({ fileKey });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (err?.status === 404) {
|
|
89
|
+
throw new NotFound({ fileKey });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new UnknownFigmaApiException({ fileKey, cause: error });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type FigmaApiErrorType =
|
|
96
|
+
| "FigmaTokenIssue"
|
|
97
|
+
| "RateLimitExceeded"
|
|
98
|
+
| "NotFound"
|
|
99
|
+
| "UnknownFigmaApiException";
|
|
100
|
+
|
|
101
|
+
export const getFigmaApiErrorType = (error: Error): FigmaApiErrorType => {
|
|
102
|
+
if (isNotFound(error)) {
|
|
103
|
+
return "NotFound";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isRateLimitExceeded(error)) {
|
|
107
|
+
return "RateLimitExceeded";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isFigmaTokenIssue(error)) {
|
|
111
|
+
return "FigmaTokenIssue";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return "UnknownFigmaApiException";
|
|
115
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GetFileResponse, Node } from '@figma/rest-api-spec';
|
|
2
|
+
import { FigmaRestApi } from '@animaapp/http-client-figma';
|
|
3
|
+
import { handleFigmaApiError } from './figmaError';
|
|
4
|
+
|
|
5
|
+
export type FigmaNode = Node;
|
|
6
|
+
export type GetFileParams = { fileKey: string; authToken?: string; figmaRestApi?: FigmaRestApi };
|
|
7
|
+
|
|
8
|
+
export type FigmaPage = { id: string; name: string };
|
|
9
|
+
export type GetFilePagesParams = {
|
|
10
|
+
fileKey: string;
|
|
11
|
+
authToken?: string;
|
|
12
|
+
figmaRestApi?: FigmaRestApi;
|
|
13
|
+
params?: Record<string, any>;
|
|
14
|
+
};
|
|
15
|
+
export type GetFilePagesResult = FigmaPage[] | undefined;
|
|
16
|
+
export type GetFileNodesParams = {
|
|
17
|
+
fileKey: string;
|
|
18
|
+
authToken?: string;
|
|
19
|
+
nodeIds: string[];
|
|
20
|
+
figmaRestApi?: FigmaRestApi;
|
|
21
|
+
params?: Record<string, any>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type GetFigmaFileResult = GetFileResponse | undefined;
|
|
25
|
+
|
|
26
|
+
export const getFigmaFile = async ({
|
|
27
|
+
fileKey,
|
|
28
|
+
authToken,
|
|
29
|
+
figmaRestApi = new FigmaRestApi(),
|
|
30
|
+
params = {},
|
|
31
|
+
}: GetFilePagesParams): Promise<GetFigmaFileResult> => {
|
|
32
|
+
if (authToken) {
|
|
33
|
+
figmaRestApi.token = authToken;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const rootFile = await figmaRestApi.files.get({
|
|
38
|
+
fileKey,
|
|
39
|
+
params,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return rootFile;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(error);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const getFileNodes = async ({
|
|
50
|
+
fileKey,
|
|
51
|
+
authToken,
|
|
52
|
+
nodeIds,
|
|
53
|
+
figmaRestApi = new FigmaRestApi(),
|
|
54
|
+
params = {},
|
|
55
|
+
}: GetFileNodesParams) => {
|
|
56
|
+
if (authToken) {
|
|
57
|
+
figmaRestApi.token = authToken;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const data = await figmaRestApi.nodes.get({
|
|
62
|
+
fileKey,
|
|
63
|
+
nodeIds,
|
|
64
|
+
params: {
|
|
65
|
+
...params,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return data.nodes;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return handleFigmaApiError(error, fileKey);
|
|
72
|
+
}
|
|
73
|
+
};
|
package/src/index.ts
ADDED
package/src/settings.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const CodegenSettingsSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
language: z.enum(["typescript", "javascript"]).optional(),
|
|
6
|
+
})
|
|
7
|
+
.and(
|
|
8
|
+
z.union([
|
|
9
|
+
z.object({
|
|
10
|
+
framework: z.literal("react"),
|
|
11
|
+
styling: z.enum([
|
|
12
|
+
"plain_css",
|
|
13
|
+
"css_modules",
|
|
14
|
+
"styled_components",
|
|
15
|
+
"tailwind",
|
|
16
|
+
"sass",
|
|
17
|
+
"scss",
|
|
18
|
+
"inline_styles",
|
|
19
|
+
]),
|
|
20
|
+
uiLibrary: z.enum(["mui", "antd", "radix", "shadcn"]).optional(),
|
|
21
|
+
}),
|
|
22
|
+
z.object({
|
|
23
|
+
framework: z.literal("html"),
|
|
24
|
+
styling: z.enum(["plain_css", "inline_styles"]),
|
|
25
|
+
enableTranslation: z.boolean().optional(),
|
|
26
|
+
}),
|
|
27
|
+
])
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// We don't use the z.infer method here because the types returned by zod aren't ergonic
|
|
31
|
+
export type CodegenSettings = {
|
|
32
|
+
language?: "typescript" | "javascript";
|
|
33
|
+
framework: "react" | "html";
|
|
34
|
+
styling:
|
|
35
|
+
| "plain_css"
|
|
36
|
+
| "css_modules"
|
|
37
|
+
| "styled_components"
|
|
38
|
+
| "tailwind"
|
|
39
|
+
| "sass"
|
|
40
|
+
| "scss"
|
|
41
|
+
| "inline_styles"
|
|
42
|
+
uiLibrary?: "mui" | "antd" | "radix" | "shadcn";
|
|
43
|
+
enableTranslation?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const validateSettings = (obj: unknown): CodegenSettings => {
|
|
47
|
+
const parsedObj = CodegenSettingsSchema.safeParse(obj);
|
|
48
|
+
|
|
49
|
+
if (parsedObj.success === false) {
|
|
50
|
+
const error = new Error("Invalid codegen settings");
|
|
51
|
+
error.cause = parsedObj.error;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parsedObj.data;
|
|
56
|
+
};
|