@sangheepark/figma-ds-mcp 0.2.1 → 0.2.2

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.
@@ -3,8 +3,8 @@
3
3
  * Phase 2 결정적 파이프라인 도구. Figma 연결 불필요 (순수 JSON 변환).
4
4
  */
5
5
  import { z } from 'zod';
6
- import { writeFileSync, mkdirSync } from 'fs';
7
- import { dirname } from 'path';
6
+ import { writeFileSync, readFileSync, mkdirSync, readdirSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
8
  // Layout 허용 key 목록
9
9
  // 주의: schema.ts (src/plugin/parser/schema.ts)의 layoutSchema와 동기화 필요.
10
10
  // 별도 패키지이므로 import 불가 — schema.ts에 key 추가 시 여기도 수동 업데이트.
@@ -354,6 +354,237 @@ function gateCheck(spec) {
354
354
  const pass = results.every(r => r.pass);
355
355
  return { pass, results };
356
356
  }
357
+ // fontWeight name → CSS number (reverse of display names in data files)
358
+ const FONT_WEIGHT_NAME_TO_NUM = {
359
+ thin: '100', hairline: '100',
360
+ extralight: '200', ultralight: '200',
361
+ light: '300',
362
+ regular: '400', normal: '400',
363
+ medium: '500',
364
+ semibold: '600', demibold: '600',
365
+ bold: '700',
366
+ extrabold: '800', ultrabold: '800',
367
+ black: '900', heavy: '900',
368
+ };
369
+ // Split name into lowercase words (CamelCase, snake_case, kebab-case)
370
+ function splitWords(name) {
371
+ return name
372
+ .replace(/([a-z])([A-Z])/g, '$1_$2')
373
+ .replace(/[-_]+/g, '_')
374
+ .toLowerCase()
375
+ .split('_')
376
+ .filter(w => w.length > 0);
377
+ }
378
+ // 3-stage component matching
379
+ function matchComponent(dsName, allComponents) {
380
+ // Stage 1: exact match (case-sensitive)
381
+ const exact = allComponents.get(dsName);
382
+ if (exact)
383
+ return { ...exact, matchType: 'exact' };
384
+ // Stage 2: case-insensitive match
385
+ const dsLower = dsName.toLowerCase();
386
+ for (const [name, data] of allComponents) {
387
+ if (name.toLowerCase() === dsLower) {
388
+ return { ...data, matchType: 'case-insensitive' };
389
+ }
390
+ }
391
+ // Stage 3: word-split intersection match
392
+ const inputWords = new Set(splitWords(dsName));
393
+ if (inputWords.size === 0)
394
+ return null;
395
+ let bestScore = 0;
396
+ let bestMatch = null;
397
+ let bestName = '';
398
+ for (const [name, data] of allComponents) {
399
+ const dataWords = new Set(splitWords(name));
400
+ let intersection = 0;
401
+ for (const w of inputWords) {
402
+ if (dataWords.has(w))
403
+ intersection++;
404
+ }
405
+ const score = intersection / inputWords.size;
406
+ if (score > bestScore || (score === bestScore && bestName.length > name.length)) {
407
+ bestScore = score;
408
+ bestMatch = data;
409
+ bestName = name;
410
+ }
411
+ }
412
+ if (bestScore >= 0.8 && bestMatch) {
413
+ return { ...bestMatch, matchType: `word-split(${bestScore.toFixed(2)})` };
414
+ }
415
+ return null;
416
+ }
417
+ function generateMapping(traversals, dataDir) {
418
+ // 1. Load all data files
419
+ const files = readdirSync(dataDir);
420
+ const allComponents = new Map();
421
+ const allVariables = [];
422
+ const allTextStyles = [];
423
+ for (const file of files) {
424
+ if (file.endsWith('-component-sets.json')) {
425
+ const library = file.replace('-component-sets.json', '');
426
+ const data = JSON.parse(readFileSync(join(dataDir, file), 'utf-8'));
427
+ for (const [name, entry] of Object.entries(data)) {
428
+ if (name === '_metadata')
429
+ continue;
430
+ allComponents.set(name, { entry: entry, library });
431
+ }
432
+ }
433
+ if (file.endsWith('-style.json') || file === 'web-ui.json') {
434
+ const library = file.replace('-style.json', '').replace('.json', '');
435
+ const data = JSON.parse(readFileSync(join(dataDir, file), 'utf-8'));
436
+ if (data.variables) {
437
+ for (const [name, v] of Object.entries(data.variables)) {
438
+ if (v.type === 'COLOR') {
439
+ allVariables.push({ name, value: v.value, type: v.type, library });
440
+ }
441
+ }
442
+ }
443
+ if (data.styles?.text) {
444
+ for (const [name, entry] of Object.entries(data.styles.text)) {
445
+ allTextStyles.push({ name, entry, library });
446
+ }
447
+ }
448
+ }
449
+ }
450
+ // 2. Extract data from traversal nodes
451
+ const dsNames = new Set();
452
+ const hexColors = new Set();
453
+ const fontSpecs = new Set();
454
+ const elementNodes = [];
455
+ function extractFromNode(node) {
456
+ if (node._ds)
457
+ dsNames.add(node._ds);
458
+ if (node.style) {
459
+ for (const value of Object.values(node.style)) {
460
+ if (typeof value === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(value)) {
461
+ hexColors.add(normalizeHex(value));
462
+ }
463
+ }
464
+ const fontSize = node.style['font-size'];
465
+ let fontWeight = node.style['font-weight'];
466
+ if (typeof fontWeight === 'number')
467
+ fontWeight = String(fontWeight);
468
+ if (typeof fontWeight === 'string') {
469
+ const normalized = FONT_WEIGHT_NAME_TO_NUM[fontWeight.toLowerCase()];
470
+ if (normalized)
471
+ fontWeight = normalized;
472
+ }
473
+ if (fontSize && fontWeight) {
474
+ fontSpecs.add(`${fontSize}/${fontWeight}`);
475
+ }
476
+ }
477
+ // Collect top-level named frames as potential elements
478
+ if (node.name && (node.type === 'frame' || node.type === 'component')) {
479
+ elementNodes.push({ name: node.name, node });
480
+ }
481
+ node.children?.forEach(extractFromNode);
482
+ }
483
+ traversals.forEach(extractFromNode);
484
+ // 3. Match components
485
+ const components = {};
486
+ for (const dsName of dsNames) {
487
+ const match = matchComponent(dsName, allComponents);
488
+ if (match) {
489
+ const comp = {
490
+ library: match.library,
491
+ 'library-key': match.entry.key,
492
+ };
493
+ if (match.entry.variants) {
494
+ const variants = {};
495
+ const defaults = {};
496
+ for (const [axis, info] of Object.entries(match.entry.variants)) {
497
+ variants[axis] = info.options;
498
+ defaults[axis] = info.default;
499
+ }
500
+ comp.variants = variants;
501
+ comp.defaults = defaults;
502
+ }
503
+ if (match.entry.properties) {
504
+ const properties = {};
505
+ for (const [propName, propInfo] of Object.entries(match.entry.properties)) {
506
+ properties[propName] = propInfo.type;
507
+ }
508
+ comp.properties = properties;
509
+ }
510
+ components[dsName] = comp;
511
+ }
512
+ }
513
+ // 4. Match tokens (HEX → variable name)
514
+ const tokens = {};
515
+ // Build reverse map: normalized HEX → variable name (semantic preferred)
516
+ const hexToVar = new Map();
517
+ for (const v of allVariables) {
518
+ const hex = normalizeHex(v.value);
519
+ const existing = hexToVar.get(hex);
520
+ // Prefer semantic (non-primitive): variables without numbers at the end
521
+ if (!existing || (!isPrimitive(v.name) && isPrimitive(existing))) {
522
+ hexToVar.set(hex, `{${v.name}}`);
523
+ }
524
+ }
525
+ for (const hex of hexColors) {
526
+ const varName = hexToVar.get(hex);
527
+ if (varName)
528
+ tokens[hex] = varName;
529
+ }
530
+ // 5. Match text styles (fontSize/fontWeight → style name)
531
+ const textStyles = {};
532
+ // Build reverse map: "14px/500" → "@StyleName"
533
+ const specToStyle = new Map();
534
+ for (const ts of allTextStyles) {
535
+ const fw = FONT_WEIGHT_NAME_TO_NUM[ts.entry.fontWeight.toLowerCase()] || ts.entry.fontWeight;
536
+ const key = `${ts.entry.fontSize}px/${fw}`;
537
+ if (!specToStyle.has(key)) {
538
+ specToStyle.set(key, `@${ts.name}`);
539
+ }
540
+ }
541
+ for (const spec of fontSpecs) {
542
+ const styleName = specToStyle.get(spec);
543
+ if (styleName)
544
+ textStyles[spec] = styleName;
545
+ }
546
+ // 6. Generate element labels
547
+ const elements = {};
548
+ for (const { name, node } of elementNodes) {
549
+ const instanceChildren = collectInstanceChildren(node);
550
+ const isLibrary = node.type === 'instance' || (node._ds && components[node._ds]);
551
+ if (isLibrary && node._ds) {
552
+ const match = components[node._ds];
553
+ const libLabel = match ? `library: ${node._ds} (${match.library})` : `library: ${node._ds}`;
554
+ elements[name] = { label: libLabel, internalLibrary: [] };
555
+ }
556
+ else {
557
+ elements[name] = {
558
+ label: '직접 빌드',
559
+ internalLibrary: instanceChildren,
560
+ };
561
+ }
562
+ }
563
+ return { elements, components, tokens, textStyles };
564
+ }
565
+ function normalizeHex(hex) {
566
+ let h = hex.toUpperCase();
567
+ // Expand 3-char to 6-char: #ABC → #AABBCC
568
+ if (/^#[0-9A-F]{3}$/i.test(h)) {
569
+ h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
570
+ }
571
+ return h;
572
+ }
573
+ function isPrimitive(varName) {
574
+ // Primitive variables typically have numbers (e.g., gray/100, blue/500)
575
+ return /\/\d+$/.test(varName) || /Alpha\//.test(varName);
576
+ }
577
+ function collectInstanceChildren(node) {
578
+ const result = [];
579
+ function walk(n) {
580
+ if (n._ds && n !== node) {
581
+ result.push(n._ds);
582
+ }
583
+ n.children?.forEach(walk);
584
+ }
585
+ node.children?.forEach(walk);
586
+ return [...new Set(result)];
587
+ }
357
588
  // --- Tool Registration ---
358
589
  export function registerPipelineTools(server) {
359
590
  // validate_traversal — B2.5
@@ -430,5 +661,61 @@ Run after validate_traversal. If outputPath is provided, spec is saved to file a
430
661
  }],
431
662
  };
432
663
  });
664
+ // generate_mapping — D5
665
+ server.tool('generate_mapping', `Generate mapping.json from traversal JSON + blueprint data files. Zero LLM involvement.
666
+ 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).
667
+ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
668
+ traversals: z.array(z.record(z.unknown())).describe('Array of traversal JSON root nodes'),
669
+ dataDir: z.string().describe('Path to blueprint data directory (e.g., .claude/blueprint/data/)'),
670
+ outputPath: z.string().describe('File path to save mapping.json'),
671
+ }, async ({ traversals, dataDir, outputPath }) => {
672
+ try {
673
+ const result = generateMapping(traversals, dataDir);
674
+ mkdirSync(dirname(outputPath), { recursive: true });
675
+ writeFileSync(outputPath, JSON.stringify(result, null, 2));
676
+ // Summary for context (not the full mapping)
677
+ const summary = {
678
+ success: true,
679
+ savedTo: outputPath,
680
+ stats: {
681
+ components: Object.keys(result.components || {}).length,
682
+ tokens: Object.keys(result.tokens || {}).length,
683
+ textStyles: Object.keys(result.textStyles || {}).length,
684
+ elements: Object.keys(result.elements || {}).length,
685
+ },
686
+ unmatchedDs: [],
687
+ };
688
+ // Report unmatched _ds names
689
+ const matchedDs = new Set(Object.keys(result.components || {}));
690
+ const allDs = new Set();
691
+ function collectDs(node) {
692
+ if (node._ds)
693
+ allDs.add(node._ds);
694
+ node.children?.forEach(collectDs);
695
+ }
696
+ traversals.forEach(collectDs);
697
+ for (const ds of allDs) {
698
+ if (!matchedDs.has(ds))
699
+ summary.unmatchedDs.push(ds);
700
+ }
701
+ return {
702
+ content: [{
703
+ type: 'text',
704
+ text: JSON.stringify(summary, null, 2),
705
+ }],
706
+ };
707
+ }
708
+ catch (err) {
709
+ return {
710
+ content: [{
711
+ type: 'text',
712
+ text: JSON.stringify({
713
+ success: false,
714
+ error: err instanceof Error ? err.message : String(err),
715
+ }, null, 2),
716
+ }],
717
+ };
718
+ }
719
+ });
433
720
  }
434
721
  //# sourceMappingURL=pipeline-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sangheepark/figma-ds-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",