@1agh/maude 0.58.1 → 0.58.3
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/apps/studio/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1491 -1491
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file figma/client.ts — the Figma REST client (DDR-216 D2/D4/D5).
|
|
3
|
+
* @scope apps/studio/figma/client.ts
|
|
4
|
+
* @purpose Fetch a document, its nodes, its rendered images and its styles —
|
|
5
|
+
* and nothing else. Produces the normalized tree in `types.ts`.
|
|
6
|
+
*
|
|
7
|
+
* @invariant SSRF CHOKEPOINT 1 — every request URL is composed from the
|
|
8
|
+
* hardcoded `API_BASE` below plus values already charset-validated
|
|
9
|
+
* by `url.ts`, each `encodeURIComponent`-ed. **No caller-supplied
|
|
10
|
+
* host, scheme, port or path prefix ever reaches a request.** This
|
|
11
|
+
* is the closure Round 1 of the DDR-216 security review attacked
|
|
12
|
+
* directly and could not break; if you are adding a method, compose
|
|
13
|
+
* it the same way rather than accepting a URL.
|
|
14
|
+
*
|
|
15
|
+
* @invariant THE TOKEN IS RESOLVED AT REQUEST TIME AND NEVER CACHED, LOGGED,
|
|
16
|
+
* OR RETURNED. `getProviderKey('figma')` is called inside the
|
|
17
|
+
* request that needs it. Errors are built from code-owned strings
|
|
18
|
+
* and the request PATH — never headers, never a raw response body
|
|
19
|
+
* (DDR-216 D2 + D10; `figma-routes.test.ts` asserts it).
|
|
20
|
+
*
|
|
21
|
+
* @invariant Figma's `/v1/images` answers with URLs Maude did not choose. This
|
|
22
|
+
* module returns them; it NEVER downloads one. That goes through
|
|
23
|
+
* `_fetch-asset.mjs`'s gate (DDR-216 D4 chokepoint 2 / D11).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getProviderKey } from '../generation/keys.ts';
|
|
27
|
+
import {
|
|
28
|
+
FigmaCapError,
|
|
29
|
+
MAX_RESPONSE_BYTES,
|
|
30
|
+
type NormalizedDocument,
|
|
31
|
+
normalizeDocument,
|
|
32
|
+
} from './types.ts';
|
|
33
|
+
|
|
34
|
+
/** The ONLY base. Never derived from input, never overridable at runtime. */
|
|
35
|
+
const API_BASE = 'https://api.figma.com/v1';
|
|
36
|
+
|
|
37
|
+
/** Whole-request budget. A slow file is a failure, not an indefinite hang. */
|
|
38
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
/** Bounded 429 backoff (DDR-216 D5) — never an unbounded sleep loop. */
|
|
41
|
+
const MAX_RETRIES = 3;
|
|
42
|
+
const MAX_BACKOFF_TOTAL_MS = 30_000;
|
|
43
|
+
/** Per-retry ceiling — 3 × this stays inside the total budget above. */
|
|
44
|
+
const MAX_SINGLE_BACKOFF_MS = 10_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `IMAGE_COST = 200` ⇒ ~30 req/min. Batch ids into as few calls as the endpoint
|
|
48
|
+
* allows; never one call per node (DDR-216 D5).
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_IMAGE_BATCH = 100;
|
|
51
|
+
|
|
52
|
+
export type FigmaErrorKind =
|
|
53
|
+
| 'not_configured'
|
|
54
|
+
| 'unauthorized'
|
|
55
|
+
| 'forbidden'
|
|
56
|
+
| 'not_found'
|
|
57
|
+
| 'rate_limited'
|
|
58
|
+
| 'too_large'
|
|
59
|
+
| 'network'
|
|
60
|
+
| 'bad_response';
|
|
61
|
+
|
|
62
|
+
export class FigmaApiError extends Error {
|
|
63
|
+
readonly kind: FigmaErrorKind;
|
|
64
|
+
readonly status?: number;
|
|
65
|
+
/** The request PATH only — never the query, never a header, never a body. */
|
|
66
|
+
readonly path: string;
|
|
67
|
+
|
|
68
|
+
constructor(kind: FigmaErrorKind, path: string, message: string, status?: number) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'FigmaApiError';
|
|
71
|
+
this.kind = kind;
|
|
72
|
+
this.path = path;
|
|
73
|
+
if (status !== undefined) this.status = status;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Fixed, code-owned messages. No upstream string is ever interpolated. */
|
|
78
|
+
const MESSAGE_BY_KIND: Readonly<Record<FigmaErrorKind, string>> = {
|
|
79
|
+
not_configured: 'No Figma token configured — add one in Settings.',
|
|
80
|
+
unauthorized: 'Figma rejected the token — check it is current and has file_content:read.',
|
|
81
|
+
forbidden: 'Figma denied access to this resource for the configured token.',
|
|
82
|
+
not_found: 'Figma has no such file or node.',
|
|
83
|
+
rate_limited: 'Figma rate-limited this import — try again in a minute.',
|
|
84
|
+
too_large: 'Figma response is too large — import a specific frame instead.',
|
|
85
|
+
network: 'Could not reach the Figma API.',
|
|
86
|
+
bad_response: 'Figma returned a response this client could not read.',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function apiError(kind: FigmaErrorKind, path: string, status?: number): FigmaApiError {
|
|
90
|
+
return new FigmaApiError(kind, path, MESSAGE_BY_KIND[kind], status);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compose a request URL. `path` is a code-owned literal with already-validated
|
|
95
|
+
* segments; `query` values are encoded here. There is deliberately no overload
|
|
96
|
+
* that accepts a full URL.
|
|
97
|
+
*/
|
|
98
|
+
function buildUrl(path: string, query?: Record<string, string | undefined>): string {
|
|
99
|
+
const url = new URL(`${API_BASE}${path}`);
|
|
100
|
+
if (query) {
|
|
101
|
+
for (const [key, value] of Object.entries(query)) {
|
|
102
|
+
if (value !== undefined && value !== '') url.searchParams.set(key, value);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sleep(ms: number): Promise<void> {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse `Retry-After` (delta-seconds only — the HTTP-date form is not worth a
|
|
114
|
+
* date parser here) and clamp it. An upstream header is untrusted input for
|
|
115
|
+
* scheduling purposes just like anything else: a hostile or buggy value must
|
|
116
|
+
* not be able to park this process for an hour.
|
|
117
|
+
*/
|
|
118
|
+
export function retryAfterMs(header: string | null): number {
|
|
119
|
+
const seconds = header ? Number.parseInt(header, 10) : Number.NaN;
|
|
120
|
+
if (!Number.isFinite(seconds) || seconds < 0) return 1_000;
|
|
121
|
+
return Math.min(seconds * 1_000, MAX_SINGLE_BACKOFF_MS);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One authenticated GET. Resolves the token at call time, enforces the response
|
|
126
|
+
* byte cap while reading, and retries only on 429 within a bounded budget.
|
|
127
|
+
*/
|
|
128
|
+
async function getJson<T>(path: string, query?: Record<string, string | undefined>): Promise<T> {
|
|
129
|
+
const token = await getProviderKey('figma');
|
|
130
|
+
if (!token) throw apiError('not_configured', path);
|
|
131
|
+
|
|
132
|
+
const url = buildUrl(path, query);
|
|
133
|
+
let spentBackoffMs = 0;
|
|
134
|
+
|
|
135
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
136
|
+
let res: Response;
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(url, {
|
|
139
|
+
headers: { 'X-Figma-Token': token, Accept: 'application/json' },
|
|
140
|
+
redirect: 'error', // the API never legitimately redirects
|
|
141
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Deliberately swallow the cause: a fetch error message can carry the URL
|
|
145
|
+
// and, on some runtimes, request detail. D10 — output is code-owned.
|
|
146
|
+
throw apiError('network', path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (res.status === 429) {
|
|
150
|
+
const waitMs = retryAfterMs(res.headers.get('Retry-After'));
|
|
151
|
+
if (attempt === MAX_RETRIES || spentBackoffMs + waitMs > MAX_BACKOFF_TOTAL_MS) {
|
|
152
|
+
throw apiError('rate_limited', path, 429);
|
|
153
|
+
}
|
|
154
|
+
spentBackoffMs += waitMs;
|
|
155
|
+
await sleep(waitMs);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
if (res.status === 401) throw apiError('unauthorized', path, 401);
|
|
161
|
+
if (res.status === 403) throw apiError('forbidden', path, 403);
|
|
162
|
+
if (res.status === 404) throw apiError('not_found', path, 404);
|
|
163
|
+
throw apiError('bad_response', path, res.status);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Trust `Content-Length` as an early-out only; the real bound is the byte
|
|
167
|
+
// count of what actually arrived (a missing or lying header must not be
|
|
168
|
+
// the thing standing between an 8 MB cap and the heap).
|
|
169
|
+
const declared = Number.parseInt(res.headers.get('Content-Length') ?? '', 10);
|
|
170
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
171
|
+
throw apiError('too_large', path, res.status);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const buffer = await readCapped(res, path);
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(new TextDecoder().decode(buffer)) as T;
|
|
177
|
+
} catch {
|
|
178
|
+
throw apiError('bad_response', path, res.status);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw apiError('rate_limited', path, 429);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read a response body, aborting the moment it exceeds the cap. Streaming
|
|
187
|
+
* rather than `res.arrayBuffer()` so an unbounded body is never fully
|
|
188
|
+
* materialized before the check — the cap has to hold against a response that
|
|
189
|
+
* does not declare its length.
|
|
190
|
+
*/
|
|
191
|
+
async function readCapped(res: Response, path: string): Promise<Uint8Array> {
|
|
192
|
+
const body = res.body;
|
|
193
|
+
if (!body) throw apiError('bad_response', path, res.status);
|
|
194
|
+
|
|
195
|
+
const reader = body.getReader();
|
|
196
|
+
const chunks: Uint8Array[] = [];
|
|
197
|
+
let total = 0;
|
|
198
|
+
try {
|
|
199
|
+
while (true) {
|
|
200
|
+
const { done, value } = await reader.read();
|
|
201
|
+
if (done) break;
|
|
202
|
+
if (!value) continue;
|
|
203
|
+
total += value.byteLength;
|
|
204
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
205
|
+
await reader.cancel().catch(() => {});
|
|
206
|
+
throw apiError('too_large', path, res.status);
|
|
207
|
+
}
|
|
208
|
+
chunks.push(value);
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
reader.releaseLock?.();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const out = new Uint8Array(total);
|
|
215
|
+
let offset = 0;
|
|
216
|
+
for (const chunk of chunks) {
|
|
217
|
+
out.set(chunk, offset);
|
|
218
|
+
offset += chunk.byteLength;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Public surface ──────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export interface FetchDocumentOptions {
|
|
226
|
+
fileKey: string;
|
|
227
|
+
surface: 'design' | 'board';
|
|
228
|
+
/** When present, fetches only this subtree — see the note below. */
|
|
229
|
+
nodeId?: string;
|
|
230
|
+
/** Figma's own tree-depth projection knob; independent of our own cap. */
|
|
231
|
+
depth?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Fetch and normalize a document.
|
|
236
|
+
*
|
|
237
|
+
* **Prefers `/nodes` whenever a node id is present.** A whole enterprise file is
|
|
238
|
+
* tens of MB and blows both the byte cap and any reasonable memory budget;
|
|
239
|
+
* whole-file import is not a viable default, frame-scoped is (DDR-216 D5).
|
|
240
|
+
*/
|
|
241
|
+
export async function fetchDocument(opts: FetchDocumentOptions): Promise<NormalizedDocument> {
|
|
242
|
+
const { fileKey, surface, nodeId, depth } = opts;
|
|
243
|
+
const meta = { fileKey, surface, origin: 'rest' as const };
|
|
244
|
+
|
|
245
|
+
if (nodeId) {
|
|
246
|
+
const path = `/files/${encodeURIComponent(fileKey)}/nodes`;
|
|
247
|
+
const body = await getJson<{ nodes?: Record<string, { document?: unknown }> }>(path, {
|
|
248
|
+
ids: nodeId,
|
|
249
|
+
depth: depth !== undefined ? String(depth) : undefined,
|
|
250
|
+
});
|
|
251
|
+
const entry = body?.nodes?.[nodeId];
|
|
252
|
+
if (!entry?.document) throw apiError('not_found', path, 404);
|
|
253
|
+
return normalizeDocument(entry.document, meta);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const path = `/files/${encodeURIComponent(fileKey)}`;
|
|
257
|
+
const body = await getJson<{ document?: unknown }>(path, {
|
|
258
|
+
depth: depth !== undefined ? String(depth) : undefined,
|
|
259
|
+
});
|
|
260
|
+
if (!body?.document) throw apiError('bad_response', path, 200);
|
|
261
|
+
return normalizeDocument(body.document, meta);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface FigmaImageResult {
|
|
265
|
+
/** nodeId → the URL Figma rendered it to, or null when it declined. */
|
|
266
|
+
images: Record<string, string | null>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Ask Figma to render nodes. Returns URLs; **downloading is not this module's
|
|
271
|
+
* job** — the returned URLs are response-controlled and must go through
|
|
272
|
+
* `_fetch-asset.mjs`'s gate (DDR-216 D4/D11).
|
|
273
|
+
*
|
|
274
|
+
* Callers batch: `MAX_IMAGE_BATCH` ids per call, never one call per node.
|
|
275
|
+
*/
|
|
276
|
+
export async function fetchImageUrls(
|
|
277
|
+
fileKey: string,
|
|
278
|
+
nodeIds: readonly string[],
|
|
279
|
+
format: 'png' | 'svg' | 'jpg' = 'png',
|
|
280
|
+
scale = 2,
|
|
281
|
+
opts: { outlineText?: boolean } = {}
|
|
282
|
+
): Promise<FigmaImageResult> {
|
|
283
|
+
if (nodeIds.length === 0) return { images: {} };
|
|
284
|
+
if (nodeIds.length > MAX_IMAGE_BATCH) {
|
|
285
|
+
throw new FigmaCapError(
|
|
286
|
+
'nodes',
|
|
287
|
+
`image batch of ${nodeIds.length} exceeds the ${MAX_IMAGE_BATCH}-id ceiling`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const path = `/images/${encodeURIComponent(fileKey)}`;
|
|
291
|
+
const body = await getJson<{ images?: Record<string, string | null>; err?: unknown }>(path, {
|
|
292
|
+
ids: nodeIds.join(','),
|
|
293
|
+
format,
|
|
294
|
+
// Scale is meaningless for svg and Figma rejects it there.
|
|
295
|
+
scale: format === 'svg' ? undefined : String(scale),
|
|
296
|
+
// Only meaningful for svg. `false` keeps real `<text>` runs in the output,
|
|
297
|
+
// which is what makes a rendered frame searchable instead of a picture of
|
|
298
|
+
// words. Omitted entirely for raster so the query stays byte-identical to
|
|
299
|
+
// what the asset lane has always sent.
|
|
300
|
+
svg_outline_text:
|
|
301
|
+
format === 'svg' && opts.outlineText !== undefined ? String(opts.outlineText) : undefined,
|
|
302
|
+
});
|
|
303
|
+
return { images: body?.images ?? {} };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** One Figma comment, charset-bounded at the edge like every other name. */
|
|
307
|
+
export interface FigmaComment {
|
|
308
|
+
id: string;
|
|
309
|
+
/** UNTRUSTED free text. Never interpreted, only ever rendered as data. */
|
|
310
|
+
message: string;
|
|
311
|
+
/** The node the pin hangs off, when it is pinned to one. */
|
|
312
|
+
nodeId?: string;
|
|
313
|
+
/** Offset within that node, or absolute page coords for a canvas-level pin. */
|
|
314
|
+
x?: number;
|
|
315
|
+
y?: number;
|
|
316
|
+
/** Thread parent. Replies group under their root pin. */
|
|
317
|
+
parentId?: string;
|
|
318
|
+
resolved: boolean;
|
|
319
|
+
/** Display handle only — provenance, never an identifier we act on (D7). */
|
|
320
|
+
author?: string;
|
|
321
|
+
createdAt?: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A file's review comments — the annotations a designer actually left.
|
|
326
|
+
*
|
|
327
|
+
* These live nowhere in the document tree, which is why a tree-walking import
|
|
328
|
+
* misses every one of them (the live StudyFi file carries 133). Unresolved and
|
|
329
|
+
* resolved both come back; dropping resolved threads throws away the record of
|
|
330
|
+
* what was already decided, so that call belongs to the caller.
|
|
331
|
+
*/
|
|
332
|
+
export async function fetchComments(fileKey: string): Promise<FigmaComment[]> {
|
|
333
|
+
const path = `/files/${encodeURIComponent(fileKey)}/comments`;
|
|
334
|
+
const body = await getJson<{ comments?: unknown[] }>(path);
|
|
335
|
+
const raw = body?.comments;
|
|
336
|
+
if (!Array.isArray(raw)) return [];
|
|
337
|
+
|
|
338
|
+
const out: FigmaComment[] = [];
|
|
339
|
+
for (const item of raw) {
|
|
340
|
+
if (!item || typeof item !== 'object') continue;
|
|
341
|
+
const c = item as Record<string, unknown>;
|
|
342
|
+
const id = typeof c.id === 'string' ? c.id : undefined;
|
|
343
|
+
if (!id) continue;
|
|
344
|
+
const meta = (c.client_meta ?? {}) as Record<string, unknown>;
|
|
345
|
+
const offset = (meta.node_offset ?? {}) as Record<string, unknown>;
|
|
346
|
+
const user = (c.user ?? {}) as Record<string, unknown>;
|
|
347
|
+
|
|
348
|
+
const num = (v: unknown): number | undefined =>
|
|
349
|
+
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
350
|
+
|
|
351
|
+
out.push({
|
|
352
|
+
id,
|
|
353
|
+
message: typeof c.message === 'string' ? c.message : '',
|
|
354
|
+
nodeId: typeof meta.node_id === 'string' ? meta.node_id : undefined,
|
|
355
|
+
x: num(offset.x) ?? num(meta.x),
|
|
356
|
+
y: num(offset.y) ?? num(meta.y),
|
|
357
|
+
parentId: typeof c.parent_id === 'string' && c.parent_id ? c.parent_id : undefined,
|
|
358
|
+
resolved: Boolean(c.resolved_at),
|
|
359
|
+
author: typeof user.handle === 'string' ? user.handle : undefined,
|
|
360
|
+
createdAt: typeof c.created_at === 'string' ? c.created_at : undefined,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface FigmaStyleMeta {
|
|
367
|
+
key: string;
|
|
368
|
+
/** UNTRUSTED — charset-bounded before it becomes a token name (DDR-216 D6). */
|
|
369
|
+
name: string;
|
|
370
|
+
styleType: string;
|
|
371
|
+
/** UNTRUSTED and DELIBERATELY UNUSED — never carried into any output. */
|
|
372
|
+
description?: string;
|
|
373
|
+
nodeId?: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Paint / text / effect styles — the Phase-4 input. */
|
|
377
|
+
export async function fetchStyles(fileKey: string): Promise<FigmaStyleMeta[]> {
|
|
378
|
+
const path = `/files/${encodeURIComponent(fileKey)}/styles`;
|
|
379
|
+
const body = await getJson<{ meta?: { styles?: unknown[] } }>(path);
|
|
380
|
+
const raw = body?.meta?.styles;
|
|
381
|
+
if (!Array.isArray(raw)) return [];
|
|
382
|
+
const out: FigmaStyleMeta[] = [];
|
|
383
|
+
for (const item of raw) {
|
|
384
|
+
if (!item || typeof item !== 'object') continue;
|
|
385
|
+
const s = item as Record<string, unknown>;
|
|
386
|
+
const key = typeof s.key === 'string' ? s.key : undefined;
|
|
387
|
+
const styleType = typeof s.style_type === 'string' ? s.style_type : undefined;
|
|
388
|
+
if (!key || !styleType) continue;
|
|
389
|
+
const entry: FigmaStyleMeta = {
|
|
390
|
+
key,
|
|
391
|
+
name: typeof s.name === 'string' ? s.name : '',
|
|
392
|
+
styleType,
|
|
393
|
+
};
|
|
394
|
+
if (typeof s.node_id === 'string') entry.nodeId = s.node_id;
|
|
395
|
+
out.push(entry);
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface FigmaVariablesResult {
|
|
401
|
+
/** False when the plan gates the endpoint — a NORMAL outcome, not an error. */
|
|
402
|
+
available: boolean;
|
|
403
|
+
raw?: unknown;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Local variables — richer than styles (modes → themes), but **Enterprise-plan
|
|
408
|
+
* gated**. A 403 here is the COMMON case (the dogfood account is Pro), so it
|
|
409
|
+
* degrades to `{ available: false }` and the caller says "using your styles"
|
|
410
|
+
* rather than surfacing a failure (DDR-216 D5).
|
|
411
|
+
*/
|
|
412
|
+
export async function fetchLocalVariables(fileKey: string): Promise<FigmaVariablesResult> {
|
|
413
|
+
const path = `/files/${encodeURIComponent(fileKey)}/variables/local`;
|
|
414
|
+
try {
|
|
415
|
+
const body = await getJson<{ meta?: unknown }>(path);
|
|
416
|
+
return { available: true, raw: body?.meta };
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (err instanceof FigmaApiError && (err.kind === 'forbidden' || err.kind === 'not_found')) {
|
|
419
|
+
return { available: false };
|
|
420
|
+
}
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Fetch MANY nodes by id, in batches, adapting to the response-byte cap.
|
|
427
|
+
*
|
|
428
|
+
* `fetchDocument` fetches one subtree, and a whole PAGE of a real file routinely
|
|
429
|
+
* blows the 8 MB cap — measured on a live StudyFi file, one page of 190
|
|
430
|
+
* top-level nodes did exactly that while its siblings came in at 1–7 k nodes.
|
|
431
|
+
* Refusing the page is the wrong answer when the caller wants the page: the
|
|
432
|
+
* cap is a bound on ONE RESPONSE, not on how much a caller may assemble.
|
|
433
|
+
*
|
|
434
|
+
* So: chunk the ids, and on a `too_large` HALVE the chunk and retry. A single
|
|
435
|
+
* id that still trips the cap is genuinely too big and is reported, not
|
|
436
|
+
* silently dropped. This keeps every individual request inside the cap while
|
|
437
|
+
* letting an import cover a page — the node-count and depth caps in
|
|
438
|
+
* `normalizeDocument` still bound the assembled total.
|
|
439
|
+
*/
|
|
440
|
+
export async function fetchNodes(
|
|
441
|
+
fileKey: string,
|
|
442
|
+
ids: readonly string[],
|
|
443
|
+
opts: { chunk?: number; onSkip?: (id: string) => void } = {}
|
|
444
|
+
): Promise<Map<string, unknown>> {
|
|
445
|
+
const out = new Map<string, unknown>();
|
|
446
|
+
const path = `/files/${encodeURIComponent(fileKey)}/nodes`;
|
|
447
|
+
|
|
448
|
+
const run = async (batch: readonly string[], chunk: number): Promise<void> => {
|
|
449
|
+
if (batch.length === 0) return;
|
|
450
|
+
try {
|
|
451
|
+
const body = await getJson<{ nodes?: Record<string, { document?: unknown }> }>(path, {
|
|
452
|
+
ids: batch.join(','),
|
|
453
|
+
});
|
|
454
|
+
for (const id of batch) {
|
|
455
|
+
const doc = body?.nodes?.[id]?.document;
|
|
456
|
+
if (doc) out.set(id, doc);
|
|
457
|
+
else opts.onSkip?.(id);
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (!(err instanceof FigmaApiError) || err.kind !== 'too_large') throw err;
|
|
461
|
+
if (batch.length === 1) {
|
|
462
|
+
// One node that alone exceeds the cap. Genuinely too big — report it
|
|
463
|
+
// rather than pretending the page imported whole.
|
|
464
|
+
opts.onSkip?.(batch[0]);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const half = Math.max(1, Math.floor(batch.length / 2));
|
|
468
|
+
await run(batch.slice(0, half), half);
|
|
469
|
+
await run(batch.slice(half), half);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const size = Math.max(1, opts.chunk ?? 8);
|
|
474
|
+
for (let i = 0; i < ids.length; i += size) {
|
|
475
|
+
await run(ids.slice(i, i + size), size);
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export interface FigmaPage {
|
|
481
|
+
id: string;
|
|
482
|
+
/** UNTRUSTED — charset-bounded before it becomes a filename. */
|
|
483
|
+
name: string;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The file's pages, cheaply (`depth: 1` — names and ids, no content). */
|
|
487
|
+
export async function fetchPages(fileKey: string): Promise<FigmaPage[]> {
|
|
488
|
+
const path = `/files/${encodeURIComponent(fileKey)}`;
|
|
489
|
+
const body = await getJson<{ document?: { children?: unknown[] } }>(path, { depth: '1' });
|
|
490
|
+
const kids = body?.document?.children;
|
|
491
|
+
if (!Array.isArray(kids)) return [];
|
|
492
|
+
const out: FigmaPage[] = [];
|
|
493
|
+
for (const k of kids) {
|
|
494
|
+
if (!k || typeof k !== 'object') continue;
|
|
495
|
+
const c = k as Record<string, unknown>;
|
|
496
|
+
if (c.type !== 'CANVAS') continue;
|
|
497
|
+
if (typeof c.id !== 'string') continue;
|
|
498
|
+
out.push({ id: c.id, name: typeof c.name === 'string' ? c.name : '' });
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export interface FigmaIdentity {
|
|
504
|
+
/** UNTRUSTED — length/charset-bounded before display, never persisted. */
|
|
505
|
+
handle: string;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** `GET /v1/me` — the Settings "probe this token" call. */
|
|
509
|
+
export async function fetchIdentity(): Promise<FigmaIdentity> {
|
|
510
|
+
const body = await getJson<{ handle?: unknown; email?: unknown }>('/me');
|
|
511
|
+
const handle = typeof body?.handle === 'string' ? body.handle : '';
|
|
512
|
+
return { handle };
|
|
513
|
+
}
|