add figma-tools skill

This commit is contained in:
2026-03-11 16:30:01 +00:00
parent 76e9ba7a6b
commit 3c3d24ccdb
12 changed files with 1375 additions and 0 deletions
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
logs/
@@ -0,0 +1,141 @@
---
name: figma-tools
description: Fetch and parse Figma design files for AI-assisted implementation. Extract layout, styles, components, and assets from Figma URLs.
---
# Figma Tools
Direct Figma API tools for fetching design data. Parse Figma files into simplified, AI-friendly structures.
## Setup
```bash
cd {baseDir}/figma-tools
pnpm install
```
Set your Figma access token:
```bash
export FIGMA_API_KEY="your-token-here"
```
Get a token from [Figma Settings → Personal Access Tokens](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens).
## Commands
### Get File or Node
```bash
# Get entire file
{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design
# Get specific frame/node
{figmaDir}/figma-tools get "https://www.figma.com/file/ABC123/My-Design?node-id=123%3A456"
# Control depth (reduce token count)
{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design --depth 2
# Output raw Figma API response
{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design --raw > raw.json
# Output as YAML
{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design --yaml
```
### Download Assets
```bash
# Download all images from a file
{figmaDir}/figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets
# Download PNGs at 2x scale
{figmaDir}/figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets --scale 2
# Download SVGs
{figmaDir}/figma-tools download https://www.figma.com/file/ABC123/My-Design ./icons --format svg
```
### Parse Local Files
```bash
# Simplify a raw Figma JSON file
{figmaDir}/figma-tools parse ./raw-figma-response.json
# Output to file
{figmaDir}/figma-tools parse ./raw.json --yaml > design.yaml
```
## When to Use
- **Implementing designs**: Fetch layout, colors, typography, spacing directly from Figma
- **Extracting assets**: Download PNGs/SVGs referenced in designs
- **Design-to-code**: Get structured data about components and styles
- **Auditing designs**: Programmatically analyze design systems
## URL Formats Supported
```
https://www.figma.com/file/{fileKey}/{title}
https://www.figma.com/file/{fileKey}/{title}?node-id={nodeId}
https://www.figma.com/design/{fileKey}/{title}
```
## Output Format
The simplified output transforms verbose Figma API responses into clean structures:
```typescript
{
name: "My Design",
lastModified: "2024-01-15T10:30:00Z",
thumbnailUrl: "...",
nodes: [{
id: "123:456",
name: "Button Primary",
type: "FRAME",
text: "Click me",
textStyle: "style_001",
fills: "fill_001",
strokes: "stroke_001",
effects: "effect_001",
layout: "layout_001",
borderRadius: "8px",
opacity: 0.9,
children: [...]
}],
components: {
"component-123": { name: "Button", description: "..." }
},
globalVars: {
styles: {
"style_001": { fontFamily: "Inter", fontSize: 16, ... },
"fill_001": [{ type: "SOLID", hex: "#FF5733" }],
"layout_001": { mode: "row", gap: "8px", justifyContent: "center" }
}
}
}
```
## Efficiency Guide
### Use --depth to Limit Data
Figma files can be huge. Use `--depth` to fetch only what you need:
```bash
{figmaDir}/figma-tools get <url> --depth 1
{figmaDir}/figma-tools get <url> --depth 2
```
### Get Specific Nodes
```bash
{figmaDir}/figma-tools get "https://www.figma.com/file/ABC/My?node-id=123%3A456"
```
### Batch Asset Downloads
```bash
{figmaDir}/figma-tools download <url> ./assets --scale 2
```
@@ -0,0 +1,4 @@
#!/usr/bin/env -S node --experimental-strip-types
// Prepend 'download' to args and run CLI
process.argv = [process.argv[0], process.argv[1], 'download', ...process.argv.slice(2)];
import('./src/cli.ts');
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env -S node --experimental-strip-types
// Prepend 'get' to args and run CLI
process.argv = [process.argv[0], process.argv[1], 'get', ...process.argv.slice(2)];
import('./src/cli.ts');
@@ -0,0 +1,4 @@
#!/usr/bin/env -S node --experimental-strip-types
// Prepend 'parse' to args and run CLI
process.argv = [process.argv[0], process.argv[1], 'parse', ...process.argv.slice(2)];
import('./src/cli.ts');
@@ -0,0 +1,21 @@
{
"name": "figma-tools",
"version": "1.0.0",
"type": "module",
"description": "Direct Figma API tools for fetching and parsing design files",
"author": "Pi",
"license": "MIT",
"packageManager": "pnpm@10.0.0",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}
@@ -0,0 +1,64 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
js-yaml:
specifier: ^4.1.0
version: 4.1.1
devDependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^22.0.0
version: 22.19.15
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@types/js-yaml@4.0.9': {}
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
argparse@2.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
typescript@5.9.3: {}
undici-types@6.21.0: {}
@@ -0,0 +1,311 @@
#!/usr/bin/env node
/**
* Figma Tools CLI
*/
import { FigmaClient, parseFigmaUrl, downloadImage } from './figma-client.ts';
import { simplifyDesign } from './simplify.ts';
import type { FigmaNode, FigmaFileResponse, FigmaNodeResponse } from './types.ts';
const API_KEY = process.env.FIGMA_API_KEY;
function showHelp(): void {
console.log(`
Figma Tools - Fetch and parse Figma design files
Usage:
figma-tools get <url> [options] Fetch and simplify Figma file/node
figma-tools download <url> <dir> Download images/assets
figma-tools parse <file> [options] Parse local JSON file
Commands:
get <figma-url>
Fetch a Figma file or specific node. Outputs simplified JSON.
Options:
--depth N Limit tree depth (1-4)
--raw Output raw API response
--yaml Output as YAML
Examples:
figma-tools get https://www.figma.com/file/ABC123/My-Design
figma-tools get "https://www.figma.com/file/ABC123/My-Design?node-id=123%3A456" --depth 2
figma-tools get https://www.figma.com/file/ABC123/My-Design --raw > raw.json
download <figma-url> <output-dir>
Download images and assets from a Figma file.
Options:
--scale N PNG scale factor (1-4, default: 1)
--format fmt Image format: png, svg, or both (default: png)
Examples:
figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets
figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets --scale 2
parse <json-file>
Simplify a raw Figma API JSON file.
Options:
--yaml Output as YAML
Examples:
figma-tools parse ./raw-figma-response.json
figma-tools parse ./raw.json --yaml > design.yaml
Setup:
export FIGMA_API_KEY="your-token-here"
Get a token from Figma Settings → Personal Access Tokens
`);
}
async function output(data: unknown, useYaml: boolean): Promise<void> {
if (useYaml) {
const yaml = await import('js-yaml');
console.log(yaml.default.dump(data, { indent: 2, lineWidth: 120 }));
} else {
console.log(JSON.stringify(data, null, 2));
}
}
async function getCommand(args: string[]): Promise<void> {
if (!API_KEY) {
console.error('Error: FIGMA_API_KEY environment variable required');
console.error('Get a token from: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens');
process.exit(1);
}
const url = args.find((a) => !a.startsWith('--'));
if (!url) {
console.error('Error: No URL provided');
console.error('Usage: figma-tools get <figma-url> [--depth N] [--raw] [--yaml]');
process.exit(1);
}
const depthArg = args.find((a, i) => a === '--depth' && args[i + 1]);
const depth = depthArg ? parseInt(args[args.indexOf(depthArg) + 1]) : null;
const raw = args.includes('--raw');
const yaml = args.includes('--yaml');
const parsed = parseFigmaUrl(url);
if (!parsed) {
console.error('Error: Invalid Figma URL');
console.error('Expected: https://www.figma.com/file/{fileKey}/{title} or with ?node-id=');
process.exit(1);
}
const client = new FigmaClient(API_KEY);
let data: FigmaFileResponse | FigmaNodeResponse;
if (parsed.nodeId) {
console.error(`Fetching node ${parsed.nodeId} from file ${parsed.fileKey}...`);
data = await client.getNode(parsed.fileKey, parsed.nodeId, depth);
} else {
console.error(`Fetching file ${parsed.fileKey}...`);
data = await client.getFile(parsed.fileKey, depth);
}
if (raw) {
await output(data, yaml);
} else {
console.error('Simplifying response...');
const simplified = simplifyDesign(data);
await output(simplified, yaml);
}
}
async function downloadCommand(args: string[]): Promise<void> {
if (!API_KEY) {
console.error('Error: FIGMA_API_KEY environment variable required');
process.exit(1);
}
const url = args.find((a) => !a.startsWith('--'));
const outputDir = args.filter((a) => !a.startsWith('--'))[1];
if (!url || !outputDir) {
console.error('Error: URL and output directory required');
console.error('Usage: figma-tools download <figma-url> <output-dir> [--scale N] [--format png|svg]');
process.exit(1);
}
const scaleArg = args.find((a, i) => a === '--scale' && args[i + 1]);
const scale = scaleArg ? parseInt(args[args.indexOf(scaleArg) + 1]) : 1;
const formatArg = args.find((a, i) => a === '--format' && args[i + 1]);
const format = formatArg ? args[args.indexOf(formatArg) + 1] : 'png';
const parsed = parseFigmaUrl(url);
if (!parsed) {
console.error('Error: Invalid Figma URL');
process.exit(1);
}
const client = new FigmaClient(API_KEY);
console.error('Fetching file structure...');
const fileData = await client.getFile(parsed.fileKey, 3);
const imageNodes: Array<{ id: string; name: string; imageRef?: string }> = [];
const svgNodes: Array<{ id: string; name: string }> = [];
function sanitizeFilename(name: string): string {
return name
.replace(/[^a-zA-Z0-9\-_./\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 100);
}
function collectNodes(nodes: FigmaNode[], parentPath = ''): void {
for (const node of nodes) {
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
if (node.fills) {
const fills = Array.isArray(node.fills) ? node.fills : [node.fills];
for (const fill of fills) {
if (fill?.type === 'IMAGE' && fill.imageRef) {
imageNodes.push({
id: node.id,
name: sanitizeFilename(currentPath) + '.png',
imageRef: fill.imageRef,
});
}
}
}
if (
['VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'POLYGON', 'LINE', 'ELLIPSE', 'RECTANGLE'].includes(
node.type
)
) {
svgNodes.push({
id: node.id,
name: sanitizeFilename(currentPath) + '.svg',
});
}
if (node.children) {
collectNodes(node.children, currentPath);
}
}
}
let nodesToProcess: FigmaNode[];
if (parsed.nodeId) {
// fileData is FigmaNodeResponse when we requested a specific node
const nodeResponse = fileData as unknown as { nodes?: Record<string, { document: FigmaNode }> };
nodesToProcess = nodeResponse.nodes?.[parsed.nodeId]?.document?.children || [];
} else {
nodesToProcess = fileData.document?.children || [];
}
collectNodes(nodesToProcess);
console.error(`Found ${imageNodes.length} image fills, ${svgNodes.length} exportable nodes`);
const results: string[] = [];
if ((format === 'png' || format === 'both') && (imageNodes.length > 0 || svgNodes.length > 0)) {
const pngIds = [
...imageNodes.map((n) => n.id),
...svgNodes.map((n) => n.id),
];
console.error(`Downloading ${pngIds.length} PNGs at ${scale}x...`);
const images = await client.getImages(parsed.fileKey, pngIds, 'png', scale);
for (const [nodeId, imageUrl] of Object.entries(images)) {
if (imageUrl) {
const node = [...imageNodes, ...svgNodes].find((n) => n.id === nodeId);
if (node) {
const fileName = node.name.endsWith('.png') ? node.name : `${node.name}.png`;
const path = await downloadImage(imageUrl, outputDir, fileName);
results.push(path);
}
}
}
}
if ((format === 'svg' || format === 'both') && svgNodes.length > 0) {
console.error(`Downloading ${svgNodes.length} SVGs...`);
const images = await client.getImages(
parsed.fileKey,
svgNodes.map((n) => n.id),
'svg'
);
for (const [nodeId, imageUrl] of Object.entries(images)) {
if (imageUrl) {
const node = svgNodes.find((n) => n.id === nodeId);
if (node) {
const path = await downloadImage(imageUrl, outputDir, node.name);
results.push(path);
}
}
}
}
console.error(`\nDownloaded ${results.length} files to: ${outputDir}`);
for (const path of results) {
console.log(path);
}
}
async function parseCommand(args: string[]): Promise<void> {
const fs = await import('fs');
const filePath = args.find((a) => !a.startsWith('--'));
if (!filePath) {
console.error('Error: No file path provided');
console.error('Usage: figma-tools parse <json-file> [--yaml]');
process.exit(1);
}
const yaml = args.includes('--yaml');
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
console.error('Simplifying...');
const simplified = simplifyDesign(raw);
await output(simplified, yaml);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
showHelp();
process.exit(0);
}
const commandArgs = args.slice(1);
try {
switch (command) {
case 'get':
await getCommand(commandArgs);
break;
case 'download':
await downloadCommand(commandArgs);
break;
case 'parse':
await parseCommand(commandArgs);
break;
default:
console.error(`Unknown command: ${command}`);
showHelp();
process.exit(1);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
main();
@@ -0,0 +1,114 @@
/**
* Figma API Client
*/
import type {
FigmaFileResponse,
FigmaNodeResponse,
FigmaImageResponse,
FigmaImageFillsResponse,
ParsedFigmaUrl,
} from './types.ts';
const BASE_URL = 'https://api.figma.com/v1';
export class FigmaClient {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
private async request<T>(endpoint: string): Promise<T> {
const url = `${BASE_URL}${endpoint}`;
const response = await fetch(url, {
headers: {
'X-Figma-Token': this.apiKey,
},
});
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error');
throw new Error(`Figma API error (${response.status}): ${text}`);
}
return response.json() as Promise<T>;
}
async getFile(fileKey: string, depth: number | null = null): Promise<FigmaFileResponse> {
const depthParam = depth ? `?depth=${depth}` : '';
return this.request<FigmaFileResponse>(`/files/${fileKey}${depthParam}`);
}
async getNode(fileKey: string, nodeId: string, depth: number | null = null): Promise<FigmaNodeResponse> {
const encodedNodeId = encodeURIComponent(nodeId);
const depthParam = depth ? `&depth=${depth}` : '';
return this.request<FigmaNodeResponse>(`/files/${fileKey}/nodes?ids=${encodedNodeId}${depthParam}`);
}
async getImageFills(fileKey: string): Promise<FigmaImageFillsResponse> {
return this.request<FigmaImageFillsResponse>(`/files/${fileKey}/images`);
}
async getImages(
fileKey: string,
nodeIds: string[],
format: 'png' | 'svg' = 'png',
scale = 1
): Promise<Record<string, string | null>> {
if (nodeIds.length === 0) return {};
const ids = nodeIds.join(',');
let endpoint: string;
if (format === 'svg') {
const params = `ids=${ids}&format=svg&svg_outline_text=true&svg_include_id=false&svg_simplify_stroke=true`;
endpoint = `/images/${fileKey}?${params}`;
} else {
endpoint = `/images/${fileKey}?ids=${ids}&format=png&scale=${scale}`;
}
const response = await this.request<FigmaImageResponse>(endpoint);
return response.images;
}
}
export function parseFigmaUrl(url: string): ParsedFigmaUrl | null {
const patterns = [
/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)(?:\/[^?]+)?(?:\?.*node-id=([0-9-]+))?/,
/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return {
fileKey: match[1],
nodeId: match[2] ? match[2].replace('-', ':') : null,
};
}
}
return null;
}
export async function downloadImage(imageUrl: string, localPath: string, fileName: string): Promise<string> {
const fs = await import('fs');
const path = await import('path');
if (!fs.existsSync(localPath)) {
fs.mkdirSync(localPath, { recursive: true });
}
const fullPath = path.join(localPath, fileName);
const response = await fetch(imageUrl, { method: 'GET' });
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
fs.writeFileSync(fullPath, Buffer.from(buffer));
return fullPath;
}
@@ -0,0 +1,472 @@
/**
* Figma Response Simplifier
*/
import type {
FigmaNode,
FigmaFileResponse,
FigmaNodeResponse,
FigmaColor,
FigmaPaint,
FigmaEffect,
FigmaTextStyle,
SimplifiedDesign,
SimplifiedNode,
SimplifiedLayout,
SimplifiedStroke,
SimplifiedEffects,
SimplifiedTextStyle,
SimplifiedFill,
} from './types.ts';
interface GlobalVars {
styles: Record<string, unknown>;
}
let varCounter = 0;
function generateVarId(prefix: string): string {
return `${prefix}_${String(++varCounter).padStart(3, '0')}`;
}
function findOrCreateVar(globalVars: GlobalVars, value: unknown, prefix: string): string {
const existing = Object.entries(globalVars.styles).find(
([, v]) => JSON.stringify(v) === JSON.stringify(value)
);
if (existing) {
return existing[0];
}
const id = generateVarId(prefix);
globalVars.styles[id] = value;
return id;
}
function isVisible(item: { visible?: boolean }): boolean {
return item.visible !== false;
}
function round(num: number): number {
return Math.round(num * 100) / 100;
}
function convertColor(rgba: FigmaColor, opacity = 1): { hex: string; opacity: number } {
const r = Math.round(rgba.r * 255);
const g = Math.round(rgba.g * 255);
const b = Math.round(rgba.b * 255);
const a = round(rgba.a * opacity);
const hex =
'#' +
((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
return { hex, opacity: a };
}
function formatRgba(rgba: FigmaColor, opacity = 1): string {
const r = Math.round(rgba.r * 255);
const g = Math.round(rgba.g * 255);
const b = Math.round(rgba.b * 255);
const a = round(rgba.a * opacity);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function formatCSSShorthand(values: {
top: number;
right: number;
bottom: number;
left: number;
}): string {
const { top, right, bottom, left } = values;
if (top === right && right === bottom && bottom === left) {
return `${top}px`;
}
if (top === bottom && right === left) {
return `${top}px ${right}px`;
}
return `${top}px ${right}px ${bottom}px ${left}px`;
}
function simplifyFill(fill: FigmaPaint): SimplifiedFill {
if (fill.type === 'IMAGE') {
return {
type: 'IMAGE',
imageRef: fill.imageRef,
scaleMode: fill.scaleMode,
};
}
if (fill.type === 'SOLID' && fill.color) {
const { hex, opacity } = convertColor(fill.color, fill.opacity);
return opacity === 1 ? hex : formatRgba(fill.color, opacity);
}
if (fill.type?.startsWith('GRADIENT_')) {
return {
type: fill.type,
gradientHandlePositions: fill.gradientHandlePositions,
gradientStops: fill.gradientStops?.map((stop) => ({
position: stop.position,
color: convertColor(stop.color),
})),
};
}
return { type: fill.type };
}
function simplifyTextStyle(style: FigmaTextStyle): SimplifiedTextStyle {
const simplified: SimplifiedTextStyle = {};
if (style.fontFamily) simplified.fontFamily = style.fontFamily;
if (style.fontWeight) simplified.fontWeight = style.fontWeight;
if (style.fontSize) simplified.fontSize = style.fontSize;
if (style.lineHeightPx && style.fontSize) {
simplified.lineHeight = `${(style.lineHeightPx / style.fontSize).toFixed(2)}em`;
}
if (style.letterSpacing && style.letterSpacing !== 0 && style.fontSize) {
simplified.letterSpacing = `${((style.letterSpacing / style.fontSize) * 100).toFixed(1)}%`;
}
if (style.textCase) simplified.textCase = style.textCase;
if (style.textAlignHorizontal) simplified.textAlignHorizontal = style.textAlignHorizontal;
if (style.textAlignVertical) simplified.textAlignVertical = style.textAlignVertical;
return simplified;
}
function simplifyStrokes(node: FigmaNode): SimplifiedStroke {
const result: SimplifiedStroke = { colors: [] };
if (node.strokes?.length) {
result.colors = node.strokes.filter(isVisible).map(simplifyFill);
}
if (typeof node.strokeWeight === 'number' && node.strokeWeight > 0) {
result.strokeWeight = `${node.strokeWeight}px`;
}
if (node.individualStrokeWeights) {
const { top, right, bottom, left } = node.individualStrokeWeights;
result.strokeWeight = formatCSSShorthand({ top, right, bottom, left });
}
if (node.strokeDashes?.length) {
result.strokeDashes = node.strokeDashes;
}
return result;
}
function simplifyEffects(node: FigmaNode): SimplifiedEffects {
if (!node.effects?.length) return {};
const visible = node.effects.filter((e) => e.visible);
const result: SimplifiedEffects = {};
const dropShadows = visible
.filter((e): e is FigmaEffect & { type: 'DROP_SHADOW'; offset: { x: number; y: number }; color: FigmaColor } => e.type === 'DROP_SHADOW')
.map((e) => `${e.offset.x}px ${e.offset.y}px ${e.radius}px ${e.spread ?? 0}px ${formatRgba(e.color)}`);
const innerShadows = visible
.filter((e): e is FigmaEffect & { type: 'INNER_SHADOW'; offset: { x: number; y: number }; color: FigmaColor } => e.type === 'INNER_SHADOW')
.map((e) => `inset ${e.offset.x}px ${e.offset.y}px ${e.radius}px ${e.spread ?? 0}px ${formatRgba(e.color)}`);
const boxShadow = [...dropShadows, ...innerShadows].join(', ');
const layerBlurs = visible
.filter((e): e is FigmaEffect & { type: 'LAYER_BLUR' } => e.type === 'LAYER_BLUR')
.map((e) => `blur(${e.radius}px)`);
const bgBlurs = visible
.filter((e): e is FigmaEffect & { type: 'BACKGROUND_BLUR' } => e.type === 'BACKGROUND_BLUR')
.map((e) => `blur(${e.radius}px)`);
if (boxShadow) {
if (node.type === 'TEXT') {
result.textShadow = boxShadow;
} else {
result.boxShadow = boxShadow;
}
}
if (layerBlurs.length) result.filter = layerBlurs.join(' ');
if (bgBlurs.length) result.backdropFilter = bgBlurs.join(' ');
return result;
}
function convertAlign(align?: string): string | undefined {
const map: Record<string, string> = {
MIN: 'flex-start',
MAX: 'flex-end',
CENTER: 'center',
SPACE_BETWEEN: 'space-between',
BASELINE: 'baseline',
};
return map[align || ''];
}
function convertSelfAlign(align?: string): string | undefined {
const map: Record<string, string> = {
MIN: 'flex-start',
MAX: 'flex-end',
CENTER: 'center',
STRETCH: 'stretch',
};
return map[align || ''];
}
function convertSizing(sizing?: string): 'fixed' | 'fill' | 'hug' | undefined {
const map: Record<string, 'fixed' | 'fill' | 'hug'> = {
FIXED: 'fixed',
FILL: 'fill',
HUG: 'hug',
};
return map[sizing || ''];
}
function simplifyLayout(node: FigmaNode, parent?: FigmaNode): SimplifiedLayout {
const layout: SimplifiedLayout = { mode: 'none' };
if (node.layoutMode && node.layoutMode !== 'NONE') {
layout.mode = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column';
layout.justifyContent = convertAlign(node.primaryAxisAlignItems);
layout.alignItems = convertAlign(node.counterAxisAlignItems);
layout.alignSelf = convertSelfAlign(node.layoutAlign);
if (node.layoutWrap === 'WRAP') {
layout.wrap = true;
}
if (node.itemSpacing) {
layout.gap = `${node.itemSpacing}px`;
}
if (node.paddingTop || node.paddingRight || node.paddingBottom || node.paddingLeft) {
layout.padding = formatCSSShorthand({
top: node.paddingTop || 0,
right: node.paddingRight || 0,
bottom: node.paddingBottom || 0,
left: node.paddingLeft || 0,
});
}
const overflow: ('x' | 'y')[] = [];
if (node.overflowDirection?.includes('HORIZONTAL')) overflow.push('x');
if (node.overflowDirection?.includes('VERTICAL')) overflow.push('y');
if (overflow.length) layout.overflowScroll = overflow;
}
if (node.layoutSizingHorizontal || node.layoutSizingVertical) {
layout.sizing = {};
if (node.layoutSizingHorizontal) {
layout.sizing.horizontal = convertSizing(node.layoutSizingHorizontal);
}
if (node.layoutSizingVertical) {
layout.sizing.vertical = convertSizing(node.layoutSizingVertical);
}
}
const parentHasLayout = parent?.layoutMode && parent.layoutMode !== 'NONE';
if (node.layoutPositioning === 'ABSOLUTE') {
layout.position = 'absolute';
}
if (node.absoluteBoundingBox && parent?.absoluteBoundingBox) {
const isAbsolute = node.layoutPositioning === 'ABSOLUTE';
const isOutsideAutoLayout = parentHasLayout && node.layoutPositioning === 'ABSOLUTE';
if (isAbsolute || isOutsideAutoLayout) {
layout.locationRelativeToParent = {
x: round(node.absoluteBoundingBox.x - parent.absoluteBoundingBox.x),
y: round(node.absoluteBoundingBox.y - parent.absoluteBoundingBox.y),
};
}
}
if (node.absoluteBoundingBox) {
const dims: { width?: number; height?: number; aspectRatio?: number } = {};
const isRow = layout.mode === 'row';
const isCol = layout.mode === 'column';
const includeWidth = !isRow || node.layoutSizingHorizontal === 'FIXED';
const includeHeight = !isCol || node.layoutSizingVertical === 'FIXED';
if (includeWidth && node.absoluteBoundingBox.width) {
dims.width = round(node.absoluteBoundingBox.width);
}
if (includeHeight && node.absoluteBoundingBox.height) {
dims.height = round(node.absoluteBoundingBox.height);
}
if (node.preserveRatio && dims.width && dims.height) {
dims.aspectRatio = round(dims.width / dims.height);
}
if (Object.keys(dims).length) {
layout.dimensions = dims;
}
}
return layout;
}
function simplifyNode(node: FigmaNode, globalVars: GlobalVars, parent?: FigmaNode): SimplifiedNode | null {
if (!node) return null;
const simplified: SimplifiedNode = {
id: node.id,
name: node.name,
type: node.type,
};
if (node.type === 'INSTANCE') {
if (node.componentId) {
simplified.componentId = node.componentId;
}
if (node.componentProperties) {
simplified.componentProperties = Object.entries(node.componentProperties).map(([name, prop]) => ({
name,
value: String(prop.value),
type: prop.type,
}));
}
}
if (node.characters) {
simplified.text = node.characters;
}
if (node.style && Object.keys(node.style).length > 0) {
simplified.textStyle = findOrCreateVar(globalVars, simplifyTextStyle(node.style), 'style');
}
if (node.fills?.length) {
const fills = node.fills.filter(isVisible).map(simplifyFill);
if (fills.length) {
simplified.fills = findOrCreateVar(globalVars, fills, 'fill');
}
}
const strokes = simplifyStrokes(node);
if (strokes.colors?.length) {
simplified.strokes = findOrCreateVar(globalVars, strokes, 'stroke');
}
const effects = simplifyEffects(node);
if (Object.keys(effects).length) {
simplified.effects = findOrCreateVar(globalVars, effects, 'effect');
}
if (typeof node.opacity === 'number' && node.opacity !== 1) {
simplified.opacity = node.opacity;
}
if (typeof node.cornerRadius === 'number') {
simplified.borderRadius = `${node.cornerRadius}px`;
} else if (node.rectangleCornerRadii?.length === 4) {
const [tl, tr, br, bl] = node.rectangleCornerRadii;
if (tl === tr && tr === br && br === bl) {
simplified.borderRadius = `${tl}px`;
} else {
simplified.borderRadius = `${tl}px ${tr}px ${br}px ${bl}px`;
}
}
const layout = simplifyLayout(node, parent);
if (Object.keys(layout).length > 1 || layout.mode !== 'none') {
simplified.layout = findOrCreateVar(globalVars, layout, 'layout');
}
if (node.children?.length) {
const children = node.children
.filter(isVisible)
.map((child) => simplifyNode(child, globalVars, node))
.filter((child): child is SimplifiedNode => child !== null);
if (children.length) {
simplified.children = children;
}
}
if (node.type === 'VECTOR') {
simplified.type = 'IMAGE-SVG';
}
return simplified;
}
export function simplifyDesign(data: FigmaFileResponse | FigmaNodeResponse): SimplifiedDesign {
const isNodeResponse = 'nodes' in data;
let nodesToProcess: FigmaNode[];
let components: Record<string, { name: string; description?: string; type?: string }> = {};
let componentSets: Record<string, { name: string; description?: string }> = {};
if (isNodeResponse) {
const nodeResponses = Object.values(data.nodes);
nodesToProcess = nodeResponses.map((n) => n.document);
for (const nodeResponse of nodeResponses) {
if (nodeResponse.components) {
Object.assign(components, nodeResponse.components);
}
if (nodeResponse.componentSets) {
Object.assign(componentSets, nodeResponse.componentSets);
}
}
} else {
nodesToProcess = data.document?.children || [];
components = data.components || {};
componentSets = data.componentSets || {};
}
const globalVars: GlobalVars = { styles: {} };
const simplifiedNodes = nodesToProcess
.filter(isVisible)
.map((node) => simplifyNode(node, globalVars))
.filter((node): node is SimplifiedNode => node !== null);
return removeEmptyKeys({
name: data.name,
lastModified: data.lastModified,
thumbnailUrl: data.thumbnailUrl || '',
nodes: simplifiedNodes,
components,
componentSets,
globalVars,
});
}
export function removeEmptyKeys<T>(obj: T): T {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(removeEmptyKeys).filter((v) => v !== undefined) as unknown as T;
}
const result = {} as Record<string, unknown>;
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const cleaned = removeEmptyKeys(value);
const isEmpty =
cleaned === undefined ||
(Array.isArray(cleaned) && cleaned.length === 0) ||
(typeof cleaned === 'object' &&
cleaned !== null &&
Object.keys(cleaned).length === 0);
if (!isEmpty) {
result[key] = cleaned;
}
}
return result as T;
}
@@ -0,0 +1,214 @@
/**
* Figma API Types
*/
export interface FigmaColor {
r: number;
g: number;
b: number;
a: number;
}
export interface FigmaPaint {
type: 'SOLID' | 'IMAGE' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND';
color?: FigmaColor;
opacity?: number;
imageRef?: string;
scaleMode?: 'FILL' | 'FIT' | 'STRETCH' | 'TILE';
gradientHandlePositions?: Array<{ x: number; y: number }>;
gradientStops?: Array<{ position: number; color: FigmaColor }>;
visible?: boolean;
}
export interface FigmaEffect {
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR';
visible: boolean;
offset?: { x: number; y: number };
radius: number;
spread?: number;
color?: FigmaColor;
}
export interface FigmaTextStyle {
fontFamily?: string;
fontWeight?: number;
fontSize?: number;
lineHeightPx?: number;
letterSpacing?: number;
textCase?: string;
textAlignHorizontal?: string;
textAlignVertical?: string;
}
export interface FigmaNode {
id: string;
name: string;
type: string;
visible?: boolean;
children?: FigmaNode[];
characters?: string;
style?: FigmaTextStyle;
fills?: FigmaPaint[];
strokes?: FigmaPaint[];
strokeWeight?: number;
individualStrokeWeights?: { top: number; right: number; bottom: number; left: number };
strokeDashes?: number[];
effects?: FigmaEffect[];
opacity?: number;
cornerRadius?: number;
rectangleCornerRadii?: number[];
layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
primaryAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'SPACE_BETWEEN' | 'BASELINE';
counterAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'SPACE_BETWEEN' | 'BASELINE';
layoutAlign?: 'MIN' | 'MAX' | 'CENTER' | 'STRETCH';
layoutPositioning?: 'AUTO' | 'ABSOLUTE';
layoutWrap?: 'NO_WRAP' | 'WRAP';
itemSpacing?: number;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
overflowDirection?: string;
layoutSizingHorizontal?: 'FIXED' | 'FILL' | 'HUG';
layoutSizingVertical?: 'FIXED' | 'FILL' | 'HUG';
layoutGrow?: number;
preserveRatio?: boolean;
absoluteBoundingBox?: {
x: number;
y: number;
width: number;
height: number;
};
clipsContent?: boolean;
componentId?: string;
componentProperties?: Record<string, { value: string | boolean | number; type: string }>;
}
export interface FigmaComponent {
name: string;
description?: string;
type?: string;
}
export interface FigmaComponentSet {
name: string;
description?: string;
}
export interface FigmaFileResponse {
name: string;
lastModified: string;
thumbnailUrl?: string;
document: FigmaNode;
components?: Record<string, FigmaComponent>;
componentSets?: Record<string, FigmaComponentSet>;
}
export interface FigmaNodeResponse {
name: string;
lastModified: string;
thumbnailUrl?: string;
nodes: Record<string, {
document: FigmaNode;
components?: Record<string, FigmaComponent>;
componentSets?: Record<string, FigmaComponentSet>;
}>;
}
export interface FigmaImageResponse {
images: Record<string, string | null>;
}
export interface FigmaImageFillsResponse {
meta: {
images: Record<string, string>;
};
}
export type FigmaResponse = FigmaFileResponse | FigmaNodeResponse;
export interface SimplifiedLayout {
mode: 'none' | 'row' | 'column';
justifyContent?: string;
alignItems?: string;
alignSelf?: string;
wrap?: boolean;
gap?: string;
padding?: string;
position?: 'absolute';
locationRelativeToParent?: { x: number; y: number };
dimensions?: { width?: number; height?: number; aspectRatio?: number };
sizing?: { horizontal?: 'fixed' | 'fill' | 'hug'; vertical?: 'fixed' | 'fill' | 'hug' };
overflowScroll?: ('x' | 'y')[];
}
export interface SimplifiedStroke {
colors: SimplifiedFill[];
strokeWeight?: string;
strokeDashes?: number[];
}
export interface SimplifiedEffects {
boxShadow?: string;
textShadow?: string;
filter?: string;
backdropFilter?: string;
}
export interface SimplifiedTextStyle {
fontFamily?: string;
fontWeight?: number;
fontSize?: number;
lineHeight?: string;
letterSpacing?: string;
textCase?: string;
textAlignHorizontal?: string;
textAlignVertical?: string;
}
export type SimplifiedFill =
| {
type?: string;
hex?: string;
rgba?: string;
opacity?: number;
imageRef?: string;
scaleMode?: string;
gradientHandlePositions?: Array<{ x: number; y: number }>;
gradientStops?: Array<{ position: number; color: { hex: string; opacity: number } }>;
}
| string;
export interface SimplifiedNode {
id: string;
name: string;
type: string;
text?: string;
textStyle?: string;
fills?: string;
strokes?: string;
effects?: string;
layout?: string;
opacity?: number;
borderRadius?: string;
componentId?: string;
componentProperties?: Array<{ name: string; value: string; type: string }>;
children?: SimplifiedNode[];
}
export interface SimplifiedDesign {
name: string;
lastModified: string;
thumbnailUrl: string;
nodes: SimplifiedNode[];
components: Record<string, { name: string; description?: string; type?: string }>;
componentSets: Record<string, { name: string; description?: string }>;
globalVars: {
styles: Record<string, unknown>;
};
}
export interface ParsedFigmaUrl {
fileKey: string;
nodeId: string | null;
}
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}