Skip to content

Commit 5b31073

Browse files
committed
ts migration, semantics and visual context
1 parent d3e64e8 commit 5b31073

File tree

12 files changed

+1404
-144
lines changed

12 files changed

+1404
-144
lines changed

src/extractors/components/extractor.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
TextInfo,
1717
ComponentExtractionOptions
1818
} from './types.js';
19+
import { detectSemanticTypeAdvanced, generateSemanticContext } from '../../tools/flutter/semantic-detection.js';
1920

2021
/**
2122
* Extract component metadata
@@ -163,7 +164,9 @@ export function analyzeChildren(
163164
nestedComponents.push(createNestedComponentInfo(child));
164165
}
165166

166-
children.push(createComponentChild(child, importance, isComponent, options));
167+
// Pass parent and siblings for better semantic detection
168+
const siblings = visibleChildren.filter(sibling => sibling.id !== child.id);
169+
children.push(createComponentChild(child, importance, isComponent, options, node, siblings));
167170
} else {
168171
// Track skipped nodes
169172
skippedNodes.push({
@@ -200,7 +203,9 @@ export function createComponentChild(
200203
node: FigmaNode,
201204
importance: number,
202205
isNestedComponent: boolean,
203-
options: Required<ComponentExtractionOptions>
206+
options: Required<ComponentExtractionOptions>,
207+
parent?: FigmaNode,
208+
siblings?: FigmaNode[]
204209
): ComponentChild {
205210
const child: ComponentChild = {
206211
nodeId: node.id,
@@ -219,7 +224,7 @@ export function createComponentChild(
219224

220225
// Extract text info for text nodes
221226
if (node.type === 'TEXT' && options.extractTextContent) {
222-
child.basicInfo.text = extractTextInfo(node);
227+
child.basicInfo.text = extractTextInfo(node, parent, siblings);
223228
}
224229
}
225230

@@ -439,7 +444,7 @@ export function extractBasicStyling(node: FigmaNode): Partial<StylingInfo> {
439444
/**
440445
* Extract enhanced text information
441446
*/
442-
export function extractTextInfo(node: FigmaNode): TextInfo | undefined {
447+
export function extractTextInfo(node: FigmaNode, parent?: FigmaNode, siblings?: FigmaNode[]): TextInfo | undefined {
443448
if (node.type !== 'TEXT') return undefined;
444449

445450
const textContent = getActualTextContent(node);
@@ -453,7 +458,7 @@ export function extractTextInfo(node: FigmaNode): TextInfo | undefined {
453458
fontWeight: node.style?.fontWeight,
454459
textAlign: node.style?.textAlignHorizontal,
455460
textCase: detectTextCase(textContent),
456-
semanticType: detectSemanticType(textContent, node.name),
461+
semanticType: detectSemanticType(textContent, node.name, node, parent, siblings),
457462
placeholder: isPlaceholder
458463
};
459464
}
@@ -670,16 +675,52 @@ function detectTextCase(content: string): 'uppercase' | 'lowercase' | 'capitaliz
670675

671676
/**
672677
* Detect semantic type of text based on content and context
678+
* Enhanced with multi-factor analysis and confidence scoring
673679
*/
674-
function detectSemanticType(content: string, nodeName: string): 'heading' | 'body' | 'label' | 'button' | 'link' | 'caption' | 'error' | 'success' | 'warning' | 'other' {
675-
const lowerContent = content.toLowerCase().trim();
676-
const lowerNodeName = nodeName.toLowerCase();
677-
680+
function detectSemanticType(
681+
content: string,
682+
nodeName: string,
683+
node?: any,
684+
parent?: any,
685+
siblings?: any[]
686+
): 'heading' | 'body' | 'label' | 'button' | 'link' | 'caption' | 'error' | 'success' | 'warning' | 'other' {
678687
// Skip detection for placeholder text
679688
if (isPlaceholderText(content)) {
680689
return 'other';
681690
}
682691

692+
// Use advanced semantic detection if node properties are available
693+
if (node) {
694+
try {
695+
const context = generateSemanticContext(node, parent, siblings);
696+
const classification = detectSemanticTypeAdvanced(content, nodeName, context, node);
697+
698+
// Only use advanced classification if confidence is high enough
699+
if (classification.confidence >= 0.6) {
700+
return classification.type;
701+
}
702+
703+
// Log reasoning for debugging (in development)
704+
if (process.env.NODE_ENV === 'development') {
705+
console.debug(`Low confidence (${classification.confidence}) for "${content}": ${classification.reasoning.join(', ')}`);
706+
}
707+
} catch (error) {
708+
// Fall back to legacy detection if advanced detection fails
709+
console.warn('Advanced semantic detection failed, using legacy method:', error);
710+
}
711+
}
712+
713+
// Legacy detection as fallback
714+
return detectSemanticTypeLegacy(content, nodeName);
715+
}
716+
717+
/**
718+
* Legacy semantic type detection (fallback)
719+
*/
720+
function detectSemanticTypeLegacy(content: string, nodeName: string): 'heading' | 'body' | 'label' | 'button' | 'link' | 'caption' | 'error' | 'success' | 'warning' | 'other' {
721+
const lowerContent = content.toLowerCase().trim();
722+
const lowerNodeName = nodeName.toLowerCase();
723+
683724
// Button text patterns - exact matches for common button labels
684725
const buttonPatterns = [
685726
/^(click|tap|press|submit|send|save|cancel|ok|yes|no|continue|next|back|close|done|finish|start|begin)$/i,

src/extractors/screens/extractor.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
calculateVisualImportance,
2222
isComponentNode
2323
} from '../components/extractor.js';
24+
import { detectSectionTypeAdvanced } from '../../tools/flutter/semantic-detection.js';
2425

2526
/**
2627
* Extract screen metadata
@@ -100,11 +101,14 @@ export function analyzeScreenSections(
100101
});
101102

102103
// Analyze each child as potential section
103-
const sectionsWithImportance = visibleChildren.map(child => ({
104-
node: child,
105-
importance: calculateSectionImportance(child),
106-
sectionType: detectSectionType(child)
107-
}));
104+
const sectionsWithImportance = visibleChildren.map(child => {
105+
const siblings = visibleChildren.filter(sibling => sibling.id !== child.id);
106+
return {
107+
node: child,
108+
importance: calculateSectionImportance(child),
109+
sectionType: detectSectionType(child, node, siblings)
110+
};
111+
});
108112

109113
// Sort by importance
110114
sectionsWithImportance.sort((a, b) => b.importance - a.importance);
@@ -193,13 +197,15 @@ function createScreenSection(
193197
components.push(createNestedComponentInfo(child));
194198
}
195199

200+
// Pass parent and siblings for better semantic detection
201+
const siblings = visibleChildren.filter(sibling => sibling.id !== child.id);
196202
children.push(createComponentChild(child, childImportance, isComponent, {
197203
maxChildNodes: 20, // Higher limit for screens
198204
maxDepth: options.maxDepth,
199205
includeHiddenNodes: options.includeHiddenNodes,
200206
prioritizeComponents: true,
201207
extractTextContent: true
202-
}));
208+
}, node, siblings));
203209
});
204210
}
205211

@@ -247,8 +253,35 @@ function calculateSectionImportance(node: FigmaNode): number {
247253

248254
/**
249255
* Detect section type based on node properties
256+
* Enhanced with multi-factor analysis and confidence scoring
257+
*/
258+
function detectSectionType(node: FigmaNode, parent?: FigmaNode, siblings?: FigmaNode[]): ScreenSection['type'] {
259+
// Try advanced detection first
260+
try {
261+
const classification = detectSectionTypeAdvanced(node, parent, siblings);
262+
263+
// Use advanced classification if confidence is high enough
264+
if (classification.confidence >= 0.6) {
265+
return classification.type as ScreenSection['type'];
266+
}
267+
268+
// Log reasoning for debugging (in development)
269+
if (process.env.NODE_ENV === 'development') {
270+
console.debug(`Low confidence (${classification.confidence}) for section "${node.name}": ${classification.reasoning.join(', ')}`);
271+
}
272+
} catch (error) {
273+
// Fall back to legacy detection if advanced detection fails
274+
console.warn('Advanced section detection failed, using legacy method:', error);
275+
}
276+
277+
// Legacy detection as fallback
278+
return detectSectionTypeLegacy(node);
279+
}
280+
281+
/**
282+
* Legacy section type detection (fallback)
250283
*/
251-
function detectSectionType(node: FigmaNode): ScreenSection['type'] {
284+
function detectSectionTypeLegacy(node: FigmaNode): ScreenSection['type'] {
252285
const name = node.name.toLowerCase();
253286
const bounds = node.absoluteBoundingBox;
254287

src/services/figma.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// services/figma.mts
22
import fetch from 'node-fetch';
3-
import type {FigmaFile, FigmaNode, FigmaFileInfo, FigmaPageInfo, NodeResponse, PageResponse} from '../types/figma.js';
3+
import type {FigmaNode, NodeResponse} from '../types/figma.js';
44
import {
55
FigmaError,
66
FigmaAuthError,
@@ -88,30 +88,6 @@ export class FigmaService {
8888
});
8989
}
9090

91-
/**
92-
* Get complete Figma file data
93-
*/
94-
async getFile(fileId: string): Promise<FigmaFile> {
95-
if (!fileId || fileId.trim().length === 0) {
96-
throw new FigmaError('File ID is required', 'INVALID_INPUT');
97-
}
98-
99-
try {
100-
const data = await this.makeRequest<FigmaFile>(`/files/${fileId}`);
101-
102-
if (!data.document || !data.name) {
103-
throw new FigmaParseError('Invalid file structure received from Figma API', data);
104-
}
105-
106-
return data;
107-
} catch (error) {
108-
if (error instanceof FigmaError) {
109-
throw error;
110-
}
111-
throw new FigmaError(`Failed to fetch file ${fileId}: ${error}`, 'FETCH_ERROR');
112-
}
113-
}
114-
11591
/**
11692
* Get specific nodes by IDs
11793
*/

src/tools/flutter/assets/assets.ts

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,9 @@ export function registerFlutterAssetTools(server: McpServer, figmaApiKey: string
136136
);
137137
}
138138

139-
// Helper functions for filtering image nodes (Figma-specific logic)
139+
// OPTIMIZED: Helper functions for filtering image nodes - only searches within target nodes
140140
async function filterImageNodes(fileId: string, targetNodeIds: string[], figmaService: any): Promise<Array<{id: string, name: string, node: any}>> {
141-
// Get the full file to access all nodes
142-
const file = await figmaService.getFile(fileId);
143-
144-
// Get the target nodes for boundary checking
141+
// OPTIMIZED: Only get the target nodes instead of the entire file (massive performance improvement)
145142
const targetNodes = await figmaService.getNodes(fileId, targetNodeIds);
146143

147144
const allNodesWithImages: Array<{id: string, name: string, node: any}> = [];
@@ -177,38 +174,17 @@ async function filterImageNodes(fileId: string, targetNodeIds: string[], figmaSe
177174
}
178175
}
179176

180-
// Extract from entire file
181-
file.document.children?.forEach((page: any) => {
182-
extractImageNodes(page);
183-
});
184-
185-
// Filter to only those within our target nodes
186-
const imageNodes = allNodesWithImages.filter(imageNode => {
187-
return targetNodeIds.some(targetId => {
188-
const targetNode = targetNodes[targetId];
189-
return targetNode && isNodeWithinTarget(imageNode.node, targetNode);
190-
});
177+
// OPTIMIZED: Extract only from target nodes instead of entire file
178+
// This eliminates the need for expensive boundary checking since we only search within target nodes
179+
Object.values(targetNodes).forEach((node: any) => {
180+
extractImageNodes(node);
191181
});
192182

193-
return imageNodes;
183+
// OPTIMIZED: No filtering needed since we only searched within target nodes
184+
return allNodesWithImages;
194185
}
195186

196-
function isNodeWithinTarget(imageNode: any, targetNode: any): boolean {
197-
if (!imageNode.absoluteBoundingBox || !targetNode.absoluteBoundingBox) {
198-
return false;
199-
}
200-
201-
const imageBounds = imageNode.absoluteBoundingBox;
202-
const targetBounds = targetNode.absoluteBoundingBox;
203-
204-
// Check if image node is within target node bounds
205-
return (
206-
imageBounds.x >= targetBounds.x &&
207-
imageBounds.y >= targetBounds.y &&
208-
imageBounds.x + imageBounds.width <= targetBounds.x + targetBounds.width &&
209-
imageBounds.y + imageBounds.height <= targetBounds.y + targetBounds.height
210-
);
211-
}
187+
// REMOVED: isNodeWithinTarget function no longer needed since we only search within target nodes
212188

213189
function toCamelCase(str: string): string {
214190
return str

src/tools/flutter/assets/svg-assets.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
downloadImage,
1010
getFileStats,
1111
updatePubspecAssets,
12-
generateSvgAssetConstants,
13-
type AssetInfo
12+
type AssetInfo,
13+
generateSvgAssetConstants
1414
} from "./asset-manager.js";
1515

1616
export function registerSvgAssetTools(server: McpServer, figmaApiKey: string) {

src/tools/flutter/components/component-tool.ts

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ import {
2020
generateStructureInspectionReport
2121
} from "./helpers.js";
2222
import {
23-
generateDeduplicatedReport,
2423
generateFlutterImplementation,
2524
generateComprehensiveDeduplicatedReport,
26-
generateStyleLibraryReport
25+
generateStyleLibraryReport,
26+
addVisualContextToDeduplicatedReport
2727
} from "./deduplicated-helpers.js";
2828

2929
import {
@@ -191,6 +191,17 @@ export function registerComponentTools(server: McpServer, figmaApiKey: string) {
191191

192192
analysisReport = generateComprehensiveDeduplicatedReport(deduplicatedAnalysis, true);
193193

194+
// Add visual context for deduplicated analysis
195+
if (parsedInput.source === 'url') {
196+
// Reconstruct the Figma URL from the parsed input
197+
const figmaUrl = `https://www.figma.com/design/${parsedInput.fileId}/?node-id=${parsedInput.nodeId}`;
198+
analysisReport += "\n\n" + addVisualContextToDeduplicatedReport(
199+
deduplicatedAnalysis,
200+
figmaUrl,
201+
parsedInput.nodeId
202+
);
203+
}
204+
194205
if (generateFlutterCode) {
195206
analysisReport += "\n\n" + generateFlutterImplementation(deduplicatedAnalysis);
196207
}
@@ -536,13 +547,10 @@ export function registerComponentTools(server: McpServer, figmaApiKey: string) {
536547
}
537548

538549
/**
539-
* Filter image nodes within a component - reuses logic from assets.mts
550+
* OPTIMIZED: Filter image nodes within a component - only searches within target nodes
540551
*/
541552
async function filterImageNodesInComponent(fileId: string, targetNodeIds: string[], figmaService: FigmaService): Promise<Array<{id: string, name: string, node: any}>> {
542-
// Get the full file to access all nodes
543-
const file = await figmaService.getFile(fileId);
544-
545-
// Get the target nodes for boundary checking
553+
// OPTIMIZED: Only get the target nodes instead of the entire file (massive performance improvement)
546554
const targetNodes = await figmaService.getNodes(fileId, targetNodeIds);
547555

548556
const allNodesWithImages: Array<{id: string, name: string, node: any}> = [];
@@ -578,38 +586,17 @@ async function filterImageNodesInComponent(fileId: string, targetNodeIds: string
578586
}
579587
}
580588

581-
// Extract from entire file
582-
file.document.children?.forEach((page: any) => {
583-
extractImageNodes(page);
584-
});
585-
586-
// Filter to only those within our target nodes
587-
const imageNodes = allNodesWithImages.filter(imageNode => {
588-
return targetNodeIds.some(targetId => {
589-
const targetNode = targetNodes[targetId];
590-
return targetNode && isNodeWithinTarget(imageNode.node, targetNode);
591-
});
589+
// OPTIMIZED: Extract only from target nodes instead of entire file
590+
// This eliminates the need for expensive boundary checking since we only search within target nodes
591+
Object.values(targetNodes).forEach((node: any) => {
592+
extractImageNodes(node);
592593
});
593594

594-
return imageNodes;
595+
// OPTIMIZED: No filtering needed since we only searched within target nodes
596+
return allNodesWithImages;
595597
}
596598

597-
function isNodeWithinTarget(imageNode: any, targetNode: any): boolean {
598-
if (!imageNode.absoluteBoundingBox || !targetNode.absoluteBoundingBox) {
599-
return false;
600-
}
601-
602-
const imageBounds = imageNode.absoluteBoundingBox;
603-
const targetBounds = targetNode.absoluteBoundingBox;
604-
605-
// Check if image node is within target node bounds
606-
return (
607-
imageBounds.x >= targetBounds.x &&
608-
imageBounds.y >= targetBounds.y &&
609-
imageBounds.x + imageBounds.width <= targetBounds.x + targetBounds.width &&
610-
imageBounds.y + imageBounds.height <= targetBounds.y + targetBounds.height
611-
);
612-
}
599+
// REMOVED: isNodeWithinTarget function no longer needed since we only search within target nodes
613600

614601
/**
615602
* Export component assets to Flutter project

0 commit comments

Comments
 (0)