@sangheepark/figma-ds-mcp 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,9 +234,14 @@ function enrichSpec(traversal, mapping) {
234
234
  node.style['line-height'] = `${Math.round(lh * 100)}%`;
235
235
  }
236
236
  }
237
+ // _ds_type 제거 (downstream 미소비 — build 전에 정리)
238
+ if ('_ds_type' in node) {
239
+ delete node['_ds_type'];
240
+ }
237
241
  // Step 6: CSS → Figma 정규화
238
- // 6-pre: overflow:hidden → layout.clip-content 변환 (6-A 필터링 전에 처리)
239
- if (node.style && node.style['overflow'] === 'hidden') {
242
+ // 6-pre: overflow → layout.clip-content 변환 (6-A 필터링 전에 처리)
243
+ const overflow = node.style && node.style['overflow'];
244
+ if (overflow === 'hidden' || overflow === 'scroll' || overflow === 'auto') {
240
245
  node.layout = node.layout || {};
241
246
  node.layout['clip-content'] = true;
242
247
  delete node.style['overflow'];
@@ -277,13 +282,13 @@ function enrichSpec(traversal, mapping) {
277
282
  }
278
283
  }
279
284
  walk(spec, 'root');
280
- // Step 6-C: root frame sizing (fill 1440px width, hug height)
285
+ // Step 6-C: root frame sizing (viewport defaults: 1440×900 PC)
281
286
  if (spec.layout) {
282
287
  if (spec.layout.width === 'fill') {
283
288
  spec.layout.width = '1440';
284
289
  }
285
290
  if (spec.layout.height === 'fill') {
286
- spec.layout.height = 'hug';
291
+ spec.layout.height = '900';
287
292
  }
288
293
  }
289
294
  return { spec, errors };
@@ -467,6 +472,127 @@ function gateCheck(spec) {
467
472
  const pass = results.every(r => r.pass);
468
473
  return { pass, results };
469
474
  }
475
+ // --- _repeat: Component Auto-Extraction ---
476
+ function extractRepeatComponents(spec, defaultCount = 3) {
477
+ const extracted = new Map(); // name → component spec
478
+ function deepClone(obj) {
479
+ return JSON.parse(JSON.stringify(obj));
480
+ }
481
+ function getRepeatCount(node) {
482
+ if (typeof node._repeat === 'number' && node._repeat > 0)
483
+ return node._repeat;
484
+ return defaultCount;
485
+ }
486
+ // Depth-first walk: process children first so nested _repeat resolves before parent
487
+ function walk(node) {
488
+ // Process children first (depth-first)
489
+ if (node.children) {
490
+ for (const child of node.children) {
491
+ walk(child);
492
+ }
493
+ }
494
+ // Now process this node's children for _repeat
495
+ if (!node.children)
496
+ return;
497
+ const newChildren = [];
498
+ for (const child of node.children) {
499
+ if (!child._repeat) {
500
+ newChildren.push(child);
501
+ continue;
502
+ }
503
+ const count = getRepeatCount(child);
504
+ const nodeAny = child;
505
+ const hasLibraryKey = !!nodeAny['library-key'];
506
+ const hasDs = !!child._ds;
507
+ // Library instance: just duplicate N times, no component extraction
508
+ if (hasLibraryKey || hasDs) {
509
+ const cleanChild = deepClone(child);
510
+ delete cleanChild._repeat;
511
+ for (let i = 0; i < count; i++) {
512
+ const instance = deepClone(cleanChild);
513
+ if (instance.name)
514
+ instance.name = `${instance.name}_${i + 1}`;
515
+ newChildren.push(instance);
516
+ }
517
+ continue;
518
+ }
519
+ // Custom frame: extract as component + insert instances
520
+ const compName = child.name || `RepeatComponent_${extracted.size + 1}`;
521
+ if (!extracted.has(compName)) {
522
+ // Extract as component
523
+ const compSpec = deepClone(child);
524
+ compSpec.type = 'component';
525
+ compSpec.name = compName;
526
+ delete compSpec._repeat;
527
+ extracted.set(compName, compSpec);
528
+ }
529
+ // Insert N instances in place
530
+ for (let i = 0; i < count; i++) {
531
+ newChildren.push({
532
+ type: 'instance',
533
+ component: compName,
534
+ name: `${compName}_${i + 1}`,
535
+ });
536
+ }
537
+ }
538
+ node.children = newChildren;
539
+ }
540
+ const page = deepClone(spec);
541
+ // Skip if root itself has _repeat (standalone component, no extraction needed)
542
+ if (page._repeat) {
543
+ delete page._repeat;
544
+ return { page, components: [] };
545
+ }
546
+ walk(page);
547
+ return { page, components: Array.from(extracted.values()) };
548
+ }
549
+ function countNodes(node) {
550
+ let count = 1;
551
+ if (node.children) {
552
+ for (const child of node.children)
553
+ count += countNodes(child);
554
+ }
555
+ return count;
556
+ }
557
+ function splitSections(pageSpec, components, outputDir) {
558
+ mkdirSync(join(outputDir, 'components'), { recursive: true });
559
+ mkdirSync(join(outputDir, 'sections'), { recursive: true });
560
+ // Save components
561
+ const compEntries = [];
562
+ for (const comp of components) {
563
+ const name = comp.name || `Component_${compEntries.length}`;
564
+ const relPath = `components/${name}.json`;
565
+ writeFileSync(join(outputDir, relPath), JSON.stringify(comp, null, 2));
566
+ compEntries.push({ name, specPath: relPath, nodeCount: countNodes(comp) });
567
+ }
568
+ // Split page into sections: use _section markers or top-level children
569
+ const sectionEntries = [];
570
+ const rootLayout = { ...(pageSpec.layout || {}) };
571
+ const rootStyle = pageSpec.style ? { ...pageSpec.style } : undefined;
572
+ const pageName = pageSpec.name || 'Page';
573
+ if (pageSpec.children && pageSpec.children.length > 0) {
574
+ for (let i = 0; i < pageSpec.children.length; i++) {
575
+ const child = pageSpec.children[i];
576
+ const name = child.name || `Section_${i}`;
577
+ const relPath = `sections/${name}.json`;
578
+ writeFileSync(join(outputDir, relPath), JSON.stringify(child, null, 2));
579
+ sectionEntries.push({ name, specPath: relPath, nodeCount: countNodes(child), order: i });
580
+ }
581
+ }
582
+ let totalNodes = 1; // root
583
+ for (const c of compEntries)
584
+ totalNodes += c.nodeCount;
585
+ for (const s of sectionEntries)
586
+ totalNodes += s.nodeCount;
587
+ const manifest = {
588
+ page: { name: pageName, rootLayout, rootStyle },
589
+ components: compEntries,
590
+ sections: sectionEntries,
591
+ stats: { totalNodes, components: compEntries.length, sections: sectionEntries.length },
592
+ };
593
+ writeFileSync(join(outputDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
594
+ return manifest;
595
+ }
470
596
  // fontWeight name → CSS number (reverse of display names in data files)
471
597
  const FONT_WEIGHT_NAME_TO_NUM = {
472
598
  thin: '100', hairline: '100',
@@ -838,16 +964,28 @@ Run after validate_traversal. If outputPath is provided, spec is saved to file a
838
964
  }],
839
965
  };
840
966
  });
967
+ // Helper: resolve traversals from either JSON param or file paths
968
+ function resolveTraversals(traversals, traversalPaths) {
969
+ if (traversalPaths && traversalPaths.length > 0) {
970
+ return traversalPaths.map(p => JSON.parse(readFileSync(p, 'utf-8')));
971
+ }
972
+ if (traversals && traversals.length > 0) {
973
+ return traversals;
974
+ }
975
+ throw new Error('Either traversals or traversalPaths must be provided');
976
+ }
841
977
  // generate_mapping — D5
842
978
  server.tool('generate_mapping', `Generate mapping.json from traversal JSON + blueprint data files. Zero LLM involvement.
843
979
  Reads *-component-sets.json and *-style.json from dataDir. Matches components (3-stage: exact → case-insensitive → word-split), tokens (HEX → variable), text styles (fontSize/fontWeight → style name).
844
980
  Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
845
- traversals: z.array(z.record(z.unknown())).describe('Array of traversal JSON root nodes'),
981
+ traversals: z.array(z.record(z.unknown())).optional().describe('Array of traversal JSON root nodes'),
982
+ traversalPaths: z.array(z.string()).optional().describe('File paths to traversal JSON files (alternative to traversals — avoids large param)'),
846
983
  dataDir: z.string().describe('Path to blueprint data directory (e.g., .claude/blueprint/data/)'),
847
984
  outputPath: z.string().describe('File path to save mapping.json'),
848
- }, async ({ traversals, dataDir, outputPath }) => {
985
+ }, async ({ traversals, traversalPaths, dataDir, outputPath }) => {
849
986
  try {
850
- const result = generateMapping(traversals, dataDir);
987
+ const tNodes = resolveTraversals(traversals, traversalPaths);
988
+ const result = generateMapping(tNodes, dataDir);
851
989
  mkdirSync(dirname(outputPath), { recursive: true });
852
990
  writeFileSync(outputPath, JSON.stringify(result, null, 2));
853
991
  // Summary for context (not the full mapping)
@@ -859,7 +997,7 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
859
997
  allDs.add(node._ds);
860
998
  node.children?.forEach(collectDs);
861
999
  }
862
- traversals.forEach(collectDs);
1000
+ tNodes.forEach(collectDs);
863
1001
  const unmatchedDs = [];
864
1002
  for (const ds of allDs) {
865
1003
  if (!matchedDs.has(ds) && !reviewDs.has(ds))
@@ -900,13 +1038,14 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
900
1038
  server.tool('run_pipeline', `Run the full spec pipeline: generate_mapping → validate → enrich → gate → tree in one call.
901
1039
  Supports 2-pass pattern: Pass 1 returns reviewNeeded (ambiguous matches). Agent decides, then Pass 2 with resolutions auto-applies decisions.
902
1040
  If no reviewNeeded, completes in 1 pass.`, {
903
- traversals: z.array(z.record(z.unknown())).describe('Array of traversal JSON root nodes'),
1041
+ traversals: z.array(z.record(z.unknown())).optional().describe('Array of traversal JSON root nodes'),
1042
+ traversalPaths: z.array(z.string()).optional().describe('File paths to traversal JSON files (alternative to traversals — avoids large param)'),
904
1043
  dataDir: z.string().describe('Path to blueprint data directory (e.g., .claude/blueprint/data/)'),
905
1044
  outputPath: z.string().describe('File path to save spec.json (mapping.json saved alongside)'),
906
1045
  resolutions: z.record(z.string()).optional().describe('Agent-decided mappings: { dsName: resolvedName }. Pass 2 only.'),
907
- }, async ({ traversals, dataDir, outputPath, resolutions }) => {
1046
+ }, async ({ traversals, traversalPaths, dataDir, outputPath, resolutions }) => {
908
1047
  try {
909
- let tNodes = traversals;
1048
+ let tNodes = resolveTraversals(traversals, traversalPaths);
910
1049
  // Step 0: Apply resolutions — replace _ds in traversal nodes
911
1050
  if (resolutions && Object.keys(resolutions).length > 0) {
912
1051
  // Deep clone to avoid mutating input
@@ -993,9 +1132,20 @@ If no reviewNeeded, completes in 1 pass.`, {
993
1132
  }],
994
1133
  };
995
1134
  }
1135
+ // Step 3.5: Extract _repeat components
1136
+ // Components are placed before pages so builder creates them first
1137
+ const finalSpecs = [];
1138
+ const allComponents = [];
1139
+ const allPages = [];
1140
+ for (const spec of specs) {
1141
+ const { page, components } = extractRepeatComponents(spec);
1142
+ allComponents.push(...components);
1143
+ allPages.push(page);
1144
+ finalSpecs.push(...components, page);
1145
+ }
996
1146
  // Step 4: Gate check each spec
997
1147
  const gateResults = [];
998
- for (const spec of specs) {
1148
+ for (const spec of finalSpecs) {
999
1149
  gateResults.push(gateCheck(spec));
1000
1150
  }
1001
1151
  const allGatesPass = gateResults.every(g => g.pass);
@@ -1003,18 +1153,25 @@ If no reviewNeeded, completes in 1 pass.`, {
1003
1153
  mkdirSync(dirname(outputPath), { recursive: true });
1004
1154
  const trees = [];
1005
1155
  const allStats = [];
1006
- if (specs.length === 1) {
1007
- writeFileSync(outputPath, JSON.stringify(specs[0], null, 2));
1008
- trees.push(specToTree(specs[0]));
1009
- allStats.push(countSpecNodes(specs[0]));
1156
+ // Also produce manifest + section files for incremental build
1157
+ const outputDir = dirname(outputPath);
1158
+ const manifests = [];
1159
+ for (const page of allPages) {
1160
+ const manifest = splitSections(page, allComponents, outputDir);
1161
+ manifests.push(manifest);
1162
+ }
1163
+ // Save full specs (legacy format) alongside manifest
1164
+ if (finalSpecs.length === 1) {
1165
+ writeFileSync(outputPath, JSON.stringify(finalSpecs[0], null, 2));
1166
+ trees.push(specToTree(finalSpecs[0]));
1167
+ allStats.push(countSpecNodes(finalSpecs[0]));
1010
1168
  }
1011
1169
  else {
1012
- // Multiple specs save each with index
1013
- for (let i = 0; i < specs.length; i++) {
1170
+ for (let i = 0; i < finalSpecs.length; i++) {
1014
1171
  const specPath = outputPath.replace(/\.json$/, `_${i}.json`);
1015
- writeFileSync(specPath, JSON.stringify(specs[i], null, 2));
1016
- trees.push(specToTree(specs[i]));
1017
- allStats.push(countSpecNodes(specs[i]));
1172
+ writeFileSync(specPath, JSON.stringify(finalSpecs[i], null, 2));
1173
+ trees.push(specToTree(finalSpecs[i]));
1174
+ allStats.push(countSpecNodes(finalSpecs[i]));
1018
1175
  }
1019
1176
  }
1020
1177
  // Collect unresolved
@@ -1037,7 +1194,9 @@ If no reviewNeeded, completes in 1 pass.`, {
1037
1194
  text: JSON.stringify({
1038
1195
  success: allGatesPass,
1039
1196
  savedTo: outputPath,
1197
+ manifestSavedTo: join(outputDir, 'manifest.json'),
1040
1198
  mappingSavedTo: mappingPath,
1199
+ manifest: manifests.length === 1 ? manifests[0] : manifests,
1041
1200
  tree: trees.length === 1 ? trees[0] : trees,
1042
1201
  stats: allStats.length === 1 ? allStats[0] : allStats,
1043
1202
  gateResult: gateResults.length === 1 ? gateResults[0] : gateResults,
@@ -57,20 +57,47 @@ function analyzeLayoutWarnings(spec) {
57
57
  return warnings;
58
58
  }
59
59
  export function registerUtilityTools(server, bridge) {
60
- // get_status — Check connection status
60
+ // get_status — Check connection status + bridge version/capabilities
61
61
  server.tool('get_status', 'Check if the Figma Bridge plugin is connected and ready', {}, async () => {
62
62
  const connected = bridge.isConnected();
63
- return {
64
- content: [{
65
- type: 'text',
66
- text: JSON.stringify({
67
- connected,
68
- message: connected
69
- ? 'Figma Bridge plugin is connected and ready.'
70
- : 'Figma Bridge plugin is NOT connected. Please open the "Code to Figma Bridge" plugin in Figma.',
71
- }, null, 2),
72
- }],
73
- };
63
+ if (!connected) {
64
+ return {
65
+ content: [{
66
+ type: 'text',
67
+ text: JSON.stringify({
68
+ connected: false,
69
+ message: 'Figma Bridge plugin is NOT connected. Please open the "Code to Figma Bridge" plugin in Figma.',
70
+ }, null, 2),
71
+ }],
72
+ };
73
+ }
74
+ try {
75
+ const bridgeStatus = await bridge.send('bridge-get-status');
76
+ return {
77
+ content: [{
78
+ type: 'text',
79
+ text: JSON.stringify({
80
+ connected: true,
81
+ message: 'Figma Bridge plugin is connected and ready.',
82
+ bridge: {
83
+ version: bridgeStatus.version || 'unknown',
84
+ capabilities: bridgeStatus.capabilities || [],
85
+ },
86
+ }, null, 2),
87
+ }],
88
+ };
89
+ }
90
+ catch {
91
+ return {
92
+ content: [{
93
+ type: 'text',
94
+ text: JSON.stringify({
95
+ connected: true,
96
+ message: 'Figma Bridge plugin is connected but bridge-get-status failed. Bridge plugin dist may need rebuild.',
97
+ }, null, 2),
98
+ }],
99
+ };
100
+ }
74
101
  });
75
102
  // validate_spec — Validate a JSON spec without creating anything
76
103
  server.tool('validate_spec', 'Validate a component/variable/style spec (JSON) without creating anything in Figma. Returns validation errors if any.', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sangheepark/figma-ds-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "MCP server for Code to Figma Bridge — bridges Claude Code to Figma plugin via WebSocket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",