add figma-tools skill
This commit is contained in:
@@ -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');
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user