diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx index c10dc4fdf..7f888484f 100644 --- a/docs/guides/figma.mdx +++ b/docs/guides/figma.mdx @@ -33,9 +33,10 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen | --- | --- | --- | | File content | Read-only | assets, components | | File metadata | Read-only | version tracking, refresh | + | Library content | Read-only | the `tokens` published-styles fallback | | Variables | Read-only | brand variables — **Figma Enterprise only** | - No Enterprise plan? Skip the Variables scope — `tokens` automatically falls back to your published styles. That's expected behavior, not an error. + No Enterprise plan? `tokens` falls back to your published styles automatically — but that fallback reads `/v1/files/:key/styles`, which needs the **Library content: Read-only** scope. Without it the command 403s with Figma's own diagnosis (*"requires the library_content:read scope"*); add the scope and re-run. The **Variables** scope stays optional (Enterprise-only). ```bash @@ -109,9 +110,10 @@ Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.me | Error | Meaning | Fix | | --- | --- | --- | | `NO_TOKEN` | `FIGMA_TOKEN` unset | Follow [One-time setup](#one-time-setup) | -| `BAD_TOKEN` (401) | Token expired or revoked | Re-mint the token | -| `FORBIDDEN` (403) | Token missing a read scope, or no access to the file | Check the read-only scopes above and file visibility | -| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — the styles fallback already ran | +| `BAD_TOKEN` | Token invalid, expired, or revoked (Figma returns **403 `Invalid token`** for bad PATs, not 401) | Re-mint the token | +| `FORBIDDEN` (403) | Missing a read scope, or no access to the file | The message names the exact scope Figma wants (e.g. `library_content:read` for the styles fallback) — add it, or check file visibility | +| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — `tokens` falls back to published styles (which needs the Library content scope above) | +| `RATE_LIMITED` (429) | Figma's per-minute limit | The client retries with backoff automatically (honoring `Retry-After`); if it still surfaces, wait a minute or batch fewer nodes | | `RATE_LIMITED` (429) | REST per-minute budget hit | Wait a minute and retry; chunk batch renders | | "Render timeout" on batch export | Too many large frames in one `/v1/images` call | Chunk to ~4 ids per call | | `ref has no node id` | Link points at a file, not a node | Copy the link with `?node-id=…` (right-click layer → Copy link) | diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 8e3465bea..d1a307ea3 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -195,34 +195,40 @@ export async function runAssetImportMany( reuseExisting(fileKey, r.nodeId, opts, version, deps, description, entity), ); const missIndexes = slots.flatMap((s, i) => (s === null ? [i] : [])); - if (missIndexes.length > 0) { - const missNodeIds = missIndexes.map((i) => refs[i]!.nodeId); - const rendered = await deps.client.renderNodes(fileKey, missNodeIds, opts); - const byNode = new Map(rendered.map((r) => [r.nodeId, r] as const)); - for (const i of missIndexes) { - const nodeId = refs[i]!.nodeId; - const r = byNode.get(nodeId); - // Keep the typed code: component import's rasterize fallback skips on - // RENDER_FAILED, so a plain Error here would abort the whole import. - if (!r || r.url === null) - throw new FigmaClientError( - "RENDER_FAILED", - `figma could not render node ${nodeId} as ${opts.format}`, + try { + if (missIndexes.length > 0) { + const missNodeIds = missIndexes.map((i) => refs[i]!.nodeId); + const rendered = await deps.client.renderNodes(fileKey, missNodeIds, opts); + const byNode = new Map(rendered.map((r) => [r.nodeId, r] as const)); + for (const i of missIndexes) { + const nodeId = refs[i]!.nodeId; + const r = byNode.get(nodeId); + // Keep the typed code: component import's rasterize fallback skips on + // RENDER_FAILED, so a plain Error here would abort the whole import. + if (!r || r.url === null) + throw new FigmaClientError( + "RENDER_FAILED", + `figma could not render node ${nodeId} as ${opts.format}`, + ); + slots[i] = await freezeAndRecord( + fileKey, + nodeId, + r.url, + r.ext, + opts, + version, + deps, + description, + entity, ); - slots[i] = await freezeAndRecord( - fileKey, - nodeId, - r.url, - r.ext, - opts, - version, - deps, - description, - entity, - ); + } } + } finally { + // Regenerate once — in `finally` so a mid-batch RENDER_FAILED still leaves + // index.md consistent with the nodes that DID freeze, not stale until the + // next import. + safeRegenerateIndex(deps.projectDir); } - safeRegenerateIndex(deps.projectDir); return slots.map((s, i) => { if (!s) throw new Error(`figma asset import produced no result for "${refInputs[i]}"`); return s; @@ -304,7 +310,14 @@ export default defineCommand({ console.log(`${verb} ${result.record.id} → ${result.record.path}`); console.log(result.snippet.html); } - if (results.length > 1) console.log(`(${results.length} nodes in 1 figma request)`); + if (results.length > 1) { + const rendered = results.filter((r) => !r.reused).length; + console.log( + rendered > 0 + ? `(${results.length} nodes, ${rendered} rendered in 1 figma request)` + : `(${results.length} nodes, all reused from cache — no figma request)`, + ); + } const { trackFigmaImport } = await import("../../telemetry/index.js"); trackFigmaImport({ phase: "asset",