Skip to content

Commit b0f9efc

Browse files
authored
feat: add component property support (BOOLEAN & TEXT) (#340)
## Summary Today when an AI fetches a Figma component, it only sees the default state — any layers hidden by boolean properties (like "On Sale" or "Show Badge") are silently dropped. The AI has no idea those layers exist or that the component supports toggling them. This PR fixes that: - **Components now show their full capability.** Hidden conditional layers are included in the output, annotated with which property controls them. Property definitions list all available properties with their types and defaults. - **Instances stay clean.** Instance output shows the resolved state with simplified property values — hidden layers are still stripped since they represent "what is" rather than "what could be." - **Property format is AI-friendly.** Explicit `{ type, defaultValue }` on definitions, `Record<name, value>` on instances, `characters` renamed to `text` to match existing fields. Covers BOOLEAN and TEXT properties. VARIANT and INSTANCE_SWAP are Phase 2.
1 parent 32d5779 commit b0f9efc

7 files changed

Lines changed: 375 additions & 46 deletions

File tree

src/extractors/built-in.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import {
1414
hasTextStyle,
1515
isTextNode,
1616
} from "~/transformers/text.js";
17+
import {
18+
simplifyComponentProperties,
19+
simplifyPropertyDefinitions,
20+
simplifyPropertyReferences,
21+
} from "~/transformers/component.js";
1722
import { hasValue, isRectangleCornerRadii } from "~/utils/identity.js";
1823
import { generateVarId, isVisible } from "~/utils/common.js";
1924
import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec";
@@ -143,23 +148,55 @@ export const visualsExtractor: ExtractorFn = (node, result, context) => {
143148
};
144149

145150
/**
146-
* Extracts component-related properties from INSTANCE nodes.
151+
* Extracts component-related properties from nodes.
152+
* Handles three cases: INSTANCE property values, property references on any node,
153+
* and property definitions on COMPONENT/COMPONENT_SET nodes.
147154
*/
148-
export const componentExtractor: ExtractorFn = (node, result, _context) => {
155+
export const componentExtractor: ExtractorFn = (node, result, context) => {
156+
// Instance nodes: componentId + simplified componentProperties
149157
if (node.type === "INSTANCE") {
150158
if (hasValue("componentId", node)) {
151159
result.componentId = node.componentId;
152160
}
153-
154-
// Add specific properties for instances of components
155161
if (hasValue("componentProperties", node)) {
156-
result.componentProperties = Object.entries(node.componentProperties ?? {}).map(
157-
([name, { value, type }]) => ({
158-
name,
159-
value: value.toString(),
160-
type,
161-
}),
162+
const props = simplifyComponentProperties(
163+
node.componentProperties as Record<string, { type: string; value: boolean | string }>,
162164
);
165+
if (Object.keys(props).length > 0) {
166+
result.componentProperties = props;
167+
}
168+
}
169+
}
170+
171+
// Any node with property references: annotate with simplified refs
172+
if (
173+
"componentPropertyReferences" in node &&
174+
node.componentPropertyReferences &&
175+
typeof node.componentPropertyReferences === "object"
176+
) {
177+
const refs = simplifyPropertyReferences(
178+
node.componentPropertyReferences as Record<string, string>,
179+
);
180+
if (Object.keys(refs).length > 0) {
181+
result.componentPropertyReferences = refs;
182+
}
183+
}
184+
185+
// Component/ComponentSet definitions: collect property definitions
186+
if (
187+
(node.type === "COMPONENT" || node.type === "COMPONENT_SET") &&
188+
"componentPropertyDefinitions" in node &&
189+
node.componentPropertyDefinitions &&
190+
typeof node.componentPropertyDefinitions === "object"
191+
) {
192+
const defs = simplifyPropertyDefinitions(
193+
node.componentPropertyDefinitions as Record<
194+
string,
195+
{ type: string; defaultValue: boolean | string }
196+
>,
197+
);
198+
if (Object.keys(defs).length > 0) {
199+
context.traversalState.componentPropertyDefinitions[node.id] = defs;
163200
}
164201
}
165202
};
@@ -177,7 +214,7 @@ function getStyleMatch(
177214
for (const key of keys) {
178215
const styleId = styleMap[key];
179216
if (styleId) {
180-
const meta = context.globalVars.extraStyles?.[styleId];
217+
const meta = context.extraStyles?.[styleId];
181218
if (meta?.name) return { name: meta.name, id: styleId };
182219
}
183220
}

src/extractors/design-extractor.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import type {
77
Style,
88
} from "@figma/rest-api-spec";
99
import { simplifyComponents, simplifyComponentSets } from "~/transformers/component.js";
10-
import { isVisible } from "~/utils/common.js";
11-
import type { ExtractorFn, TraversalOptions, SimplifiedDesign, TraversalContext } from "./types.js";
10+
import type { ExtractorFn, TraversalOptions, SimplifiedDesign } from "./types.js";
1211
import { extractFromDesign } from "./node-walker.js";
1312

1413
/**
@@ -24,20 +23,20 @@ export async function simplifyRawFigmaObject(
2423
parseAPIResponse(apiResponse);
2524

2625
// Process nodes using the flexible extractor system
27-
const globalVars: TraversalContext["globalVars"] = { styles: {}, extraStyles };
28-
const { nodes: extractedNodes, globalVars: finalGlobalVars } = await extractFromDesign(
29-
rawNodes,
30-
nodeExtractors,
31-
options,
32-
globalVars,
33-
);
26+
const {
27+
nodes: extractedNodes,
28+
globalVars: finalGlobalVars,
29+
traversalState,
30+
} = await extractFromDesign(rawNodes, nodeExtractors, options, { styles: {} }, extraStyles);
3431

35-
// Return complete design
3632
return {
3733
...metadata,
3834
nodes: extractedNodes,
39-
components: simplifyComponents(components),
40-
componentSets: simplifyComponentSets(componentSets),
35+
components: simplifyComponents(components, traversalState.componentPropertyDefinitions),
36+
componentSets: simplifyComponentSets(
37+
componentSets,
38+
traversalState.componentPropertyDefinitions,
39+
),
4140
globalVars: { styles: finalGlobalVars.styles },
4241
};
4342
}
@@ -65,15 +64,15 @@ function parseAPIResponse(data: GetFileResponse | GetFileNodesResponse) {
6564
Object.assign(extraStyles, nodeResponse.styles);
6665
}
6766
});
68-
nodesToParse = nodeResponses.map((n) => n.document).filter(isVisible);
67+
nodesToParse = nodeResponses.map((n) => n.document);
6968
} else {
7069
// GetFileResponse
7170
Object.assign(aggregatedComponents, data.components);
7271
Object.assign(aggregatedComponentSets, data.componentSets);
7372
if (data.styles) {
7473
extraStyles = data.styles;
7574
}
76-
nodesToParse = data.document.children.filter(isVisible);
75+
nodesToParse = data.document.children;
7776
}
7877

7978
const { name } = data;

src/extractors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type {
44
SimplifiedNode,
55
TraversalContext,
66
TraversalOptions,
7+
TraversalState,
78
GlobalVars,
89
StyleTypes,
910
} from "./types.js";

src/extractors/node-walker.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec";
22
import { isVisible } from "~/utils/common.js";
33
import { hasValue } from "~/utils/identity.js";
4+
import type { Style } from "@figma/rest-api-spec";
45
import type {
56
ExtractorFn,
67
TraversalContext,
78
TraversalOptions,
9+
TraversalState,
810
GlobalVars,
911
SimplifiedNode,
1012
} from "./types.js";
@@ -41,24 +43,32 @@ export async function extractFromDesign(
4143
extractors: ExtractorFn[],
4244
options: TraversalOptions = {},
4345
globalVars: GlobalVars = { styles: {} },
44-
): Promise<{ nodes: SimplifiedNode[]; globalVars: GlobalVars }> {
46+
extraStyles?: Record<string, Style>,
47+
): Promise<{
48+
nodes: SimplifiedNode[];
49+
globalVars: GlobalVars;
50+
traversalState: TraversalState;
51+
}> {
4552
const context: TraversalContext = {
4653
globalVars,
54+
extraStyles,
4755
currentDepth: 0,
56+
traversalState: { componentPropertyDefinitions: {} },
4857
};
4958

5059
nodesProcessed = 0;
5160

5261
const processedNodes: SimplifiedNode[] = [];
5362
for (const node of nodes) {
54-
if (!shouldProcessNode(node, options)) continue;
63+
if (!shouldProcessNode(node, context, options)) continue;
5564
const result = await processNodeWithExtractors(node, extractors, context, options);
5665
if (result !== null) processedNodes.push(result);
5766
}
5867

5968
return {
6069
nodes: processedNodes,
6170
globalVars: context.globalVars,
71+
traversalState: context.traversalState,
6272
};
6373
}
6474

@@ -71,7 +81,7 @@ async function processNodeWithExtractors(
7181
context: TraversalContext,
7282
options: TraversalOptions,
7383
): Promise<SimplifiedNode | null> {
74-
if (!shouldProcessNode(node, options)) {
84+
if (!shouldProcessNode(node, context, options)) {
7585
return null;
7686
}
7787

@@ -95,13 +105,20 @@ async function processNodeWithExtractors(
95105
...context,
96106
currentDepth: context.currentDepth + 1,
97107
parent: node,
108+
// COMPONENT nodes define properties; INSTANCE nodes resolve them
109+
insideComponentDefinition:
110+
node.type === "COMPONENT" || node.type === "COMPONENT_SET"
111+
? true
112+
: node.type === "INSTANCE"
113+
? false
114+
: context.insideComponentDefinition,
98115
};
99116

100117
// Use the same pattern as the existing parseNode function
101118
if (hasValue("children", node) && node.children.length > 0) {
102119
const children: SimplifiedNode[] = [];
103120
for (const child of node.children) {
104-
if (!shouldProcessNode(child, options)) continue;
121+
if (!shouldProcessNode(child, childContext, options)) continue;
105122
const processed = await processNodeWithExtractors(child, extractors, childContext, options);
106123
if (processed !== null) children.push(processed);
107124
}
@@ -125,13 +142,23 @@ async function processNodeWithExtractors(
125142
/**
126143
* Determine if a node should be processed based on filters.
127144
*/
128-
function shouldProcessNode(node: FigmaDocumentNode, options: TraversalOptions): boolean {
129-
// Skip invisible nodes
145+
function shouldProcessNode(
146+
node: FigmaDocumentNode,
147+
context: TraversalContext,
148+
options: TraversalOptions,
149+
): boolean {
130150
if (!isVisible(node)) {
131-
return false;
151+
// Rescue hidden nodes controlled by a boolean property inside component definitions
152+
const hasVisibleRef =
153+
"componentPropertyReferences" in node &&
154+
node.componentPropertyReferences &&
155+
typeof node.componentPropertyReferences === "object" &&
156+
"visible" in node.componentPropertyReferences;
157+
if (!(hasVisibleRef && context.insideComponentDefinition)) {
158+
return false;
159+
}
132160
}
133161

134-
// Apply custom node filter if provided
135162
if (options.nodeFilter && !options.nodeFilter(node)) {
136163
return false;
137164
}

src/extractors/types.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import type { SimplifiedLayout } from "~/transformers/layout.js";
44
import type { SimplifiedFill, SimplifiedStroke } from "~/transformers/style.js";
55
import type { SimplifiedEffects } from "~/transformers/effects.js";
66
import type {
7-
ComponentProperties,
87
SimplifiedComponentDefinition,
98
SimplifiedComponentSetDefinition,
9+
SimplifiedPropertyDefinition,
1010
} from "~/transformers/component.js";
1111

1212
export type StyleTypes =
@@ -22,9 +22,16 @@ export type GlobalVars = {
2222
};
2323

2424
export interface TraversalContext {
25-
globalVars: GlobalVars & { extraStyles?: Record<string, Style> };
25+
globalVars: GlobalVars;
26+
extraStyles?: Record<string, Style>;
2627
currentDepth: number;
2728
parent?: FigmaDocumentNode;
29+
insideComponentDefinition?: boolean;
30+
traversalState: TraversalState;
31+
}
32+
33+
export interface TraversalState {
34+
componentPropertyDefinitions: Record<string, Record<string, SimplifiedPropertyDefinition>>;
2835
}
2936

3037
export interface TraversalOptions {
@@ -87,9 +94,9 @@ export interface SimplifiedNode {
8794
borderRadius?: string;
8895
// layout & alignment
8996
layout?: string;
90-
// for rect-specific strokes, etc.
9197
componentId?: string;
92-
componentProperties?: ComponentProperties[];
98+
componentProperties?: Record<string, boolean | string>;
99+
componentPropertyReferences?: Record<string, string>;
93100
// children
94101
children?: SimplifiedNode[];
95102
}

0 commit comments

Comments
 (0)