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