@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.
- package/dist/tools/pipeline-tools.js +289 -2
- package/package.json +1 -1
|
@@ -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