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