@fragments-sdk/cli 0.4.0 → 0.4.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/bin.js +11 -11
- package/dist/{chunk-OZQ7Z6C3.js → chunk-5CKYLCJH.js} +28 -1
- package/dist/{chunk-OZQ7Z6C3.js.map → chunk-5CKYLCJH.js.map} +1 -1
- package/dist/{chunk-5JNME72P.js → chunk-5ZYEOHYK.js} +11 -9
- package/dist/chunk-5ZYEOHYK.js.map +1 -0
- package/dist/{chunk-FYIYMXGA.js → chunk-G3M3MPQ6.js} +13 -2
- package/dist/chunk-G3M3MPQ6.js.map +1 -0
- package/dist/{chunk-LDKNZ55O.js → chunk-J4SI5RIH.js} +3 -3
- package/dist/{chunk-ODXQAQQX.js → chunk-ZFKGX3QK.js} +4 -4
- package/dist/{core-F3VT277E.js → core-LNXDLXDP.js} +2 -2
- package/dist/{generate-PNIUR75D.js → generate-OIXXHOWR.js} +3 -3
- package/dist/index.js +4 -4
- package/dist/{init-ON6WYG66.js → init-EVPXIDW4.js} +3 -3
- package/dist/mcp-bin.js +44 -43
- package/dist/mcp-bin.js.map +1 -1
- package/dist/scan-YVYD64GD.js +12 -0
- package/dist/{service-U7AR2PC2.js → service-K52ORLCJ.js} +3 -3
- package/dist/{static-viewer-QL2SCWYB.js → static-viewer-JNQIHA4B.js} +2 -2
- package/dist/{test-PBPKJ4WJ.js → test-USARUEFW.js} +8 -4
- package/dist/test-USARUEFW.js.map +1 -0
- package/dist/{tokens-4J4PRIGT.js → tokens-C6YHBOQE.js} +4 -4
- package/dist/{viewer-6VCZMA3T.js → viewer-H7TVFT4E.js} +14 -14
- package/dist/{viewer-6VCZMA3T.js.map → viewer-H7TVFT4E.js.map} +1 -1
- package/package.json +2 -1
- package/src/core/parser.ts +48 -0
- package/src/core/storyAdapter.ts +1 -1
- package/src/core/storybook-csf.ts +11 -0
- package/src/core/types.ts +1 -1
- package/src/mcp/__tests__/findFragmentsJson.test.ts +308 -0
- package/src/mcp/server.ts +59 -55
- package/src/service/enhance/doc-extractor.ts +2 -2
- package/src/service/enhance/props-extractor.ts +1 -1
- package/src/service/enhance/storybook-parser.ts +1 -1
- package/src/service/figma.ts +2 -2
- package/src/service/metrics-store.ts +2 -1
- package/src/service/patch-generator.ts +2 -1
- package/src/test/reporters/junit.ts +7 -3
- package/src/test/runner.ts +4 -4
- package/src/test/watch.ts +2 -2
- package/src/viewer/components/CodePanel.tsx +1 -1
- package/src/viewer/components/FigmaEmbed.tsx +1 -1
- package/src/viewer/jsx-parser.ts +2 -1
- package/src/viewer/utils/colorSchemes.ts +3 -3
- package/dist/chunk-5JNME72P.js.map +0 -1
- package/dist/chunk-FYIYMXGA.js.map +0 -1
- package/dist/scan-E6U644RS.js +0 -12
- package/dist/test-PBPKJ4WJ.js.map +0 -1
- /package/dist/{chunk-LDKNZ55O.js.map → chunk-J4SI5RIH.js.map} +0 -0
- /package/dist/{chunk-ODXQAQQX.js.map → chunk-ZFKGX3QK.js.map} +0 -0
- /package/dist/{core-F3VT277E.js.map → core-LNXDLXDP.js.map} +0 -0
- /package/dist/{generate-PNIUR75D.js.map → generate-OIXXHOWR.js.map} +0 -0
- /package/dist/{init-ON6WYG66.js.map → init-EVPXIDW4.js.map} +0 -0
- /package/dist/{scan-E6U644RS.js.map → scan-YVYD64GD.js.map} +0 -0
- /package/dist/{service-U7AR2PC2.js.map → service-K52ORLCJ.js.map} +0 -0
- /package/dist/{static-viewer-QL2SCWYB.js.map → static-viewer-JNQIHA4B.js.map} +0 -0
- /package/dist/{tokens-4J4PRIGT.js.map → tokens-C6YHBOQE.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fragments-sdk/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "CLI, MCP server, and dev tools for Fragments design system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"scripts": {
|
|
101
101
|
"build": "tsup",
|
|
102
102
|
"dev": "tsup --watch",
|
|
103
|
+
"lint": "eslint src",
|
|
103
104
|
"test": "vitest run",
|
|
104
105
|
"typecheck": "tsc --noEmit",
|
|
105
106
|
"clean": "rm -rf dist"
|
package/src/core/parser.ts
CHANGED
|
@@ -408,6 +408,51 @@ function extractVariants(
|
|
|
408
408
|
return variants;
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Remove common leading whitespace from all lines (dedent).
|
|
413
|
+
* This handles template literals and JSX that have extra indentation from code formatting.
|
|
414
|
+
*
|
|
415
|
+
* Special handling: If the first line has no indentation (common after .trim()),
|
|
416
|
+
* we calculate minimum indent from subsequent lines only.
|
|
417
|
+
*/
|
|
418
|
+
function dedent(str: string): string {
|
|
419
|
+
const lines = str.split('\n');
|
|
420
|
+
|
|
421
|
+
if (lines.length <= 1) {
|
|
422
|
+
return str;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Check if first line has no indentation
|
|
426
|
+
const firstLineIndent = lines[0].match(/^(\s*)/)?.[1].length ?? 0;
|
|
427
|
+
const startIndex = firstLineIndent === 0 ? 1 : 0;
|
|
428
|
+
|
|
429
|
+
// Find the minimum indentation (ignoring empty lines)
|
|
430
|
+
let minIndent = Infinity;
|
|
431
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
432
|
+
const line = lines[i];
|
|
433
|
+
if (line.trim() === '') continue;
|
|
434
|
+
const match = line.match(/^(\s*)/);
|
|
435
|
+
if (match) {
|
|
436
|
+
minIndent = Math.min(minIndent, match[1].length);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// If no indentation found, return as-is
|
|
441
|
+
if (minIndent === Infinity || minIndent === 0) {
|
|
442
|
+
return str;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Remove the common indentation from all lines (except first if it had no indent)
|
|
446
|
+
return lines
|
|
447
|
+
.map((line, index) => {
|
|
448
|
+
if (index === 0 && firstLineIndent === 0) {
|
|
449
|
+
return line; // Keep first line as-is
|
|
450
|
+
}
|
|
451
|
+
return line.slice(minIndent);
|
|
452
|
+
})
|
|
453
|
+
.join('\n');
|
|
454
|
+
}
|
|
455
|
+
|
|
411
456
|
/**
|
|
412
457
|
* Extract the code from a render function.
|
|
413
458
|
*/
|
|
@@ -428,6 +473,9 @@ function extractRenderCode(
|
|
|
428
473
|
code = code.slice(1, -1).trim();
|
|
429
474
|
}
|
|
430
475
|
|
|
476
|
+
// Dedent the code to remove common leading whitespace
|
|
477
|
+
code = dedent(code);
|
|
478
|
+
|
|
431
479
|
return code;
|
|
432
480
|
}
|
|
433
481
|
|
package/src/core/storyAdapter.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
12
|
-
import { toId, storyNameFromExport, isExportStory } from "
|
|
12
|
+
import { toId, storyNameFromExport, isExportStory } from "./storybook-csf.js";
|
|
13
13
|
import type {
|
|
14
14
|
SegmentDefinition,
|
|
15
15
|
SegmentMeta,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
toId as storybookToId,
|
|
3
|
+
storyNameFromExport as storybookStoryNameFromExport,
|
|
4
|
+
isExportStory as storybookIsExportStory,
|
|
5
|
+
} from "@storybook/csf";
|
|
6
|
+
|
|
7
|
+
export const toId: typeof storybookToId = (...args) => storybookToId(...args);
|
|
8
|
+
export const storyNameFromExport: typeof storybookStoryNameFromExport = (...args) =>
|
|
9
|
+
storybookStoryNameFromExport(...args);
|
|
10
|
+
export const isExportStory: typeof storybookIsExportStory = (...args) =>
|
|
11
|
+
storybookIsExportStory(...args);
|
package/src/core/types.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { ComponentType, ReactNode, JSX } from "react";
|
|
|
5
5
|
* This type is intentionally broad to support various React component patterns
|
|
6
6
|
* including FC, forwardRef, memo, and class components across different React versions.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
export type SegmentComponent<TProps = any> =
|
|
10
10
|
| ComponentType<TProps>
|
|
11
11
|
| ((props: TProps) => ReactNode | JSX.Element | null);
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, realpathSync, symlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { findFragmentsJson } from '../server.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a temporary directory tree for testing findFragmentsJson.
|
|
9
|
+
* Uses realpathSync to normalize macOS /var -> /private/var symlinks,
|
|
10
|
+
* ensuring path comparisons work reliably.
|
|
11
|
+
*/
|
|
12
|
+
function createTmpDir(): string {
|
|
13
|
+
const raw = join(tmpdir(), `fragments-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
|
+
mkdirSync(raw, { recursive: true });
|
|
15
|
+
return realpathSync(raw);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeJson(path: string, data: unknown): void {
|
|
19
|
+
writeFileSync(path, JSON.stringify(data));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('findFragmentsJson', () => {
|
|
23
|
+
let root: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
root = createTmpDir();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(root, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('Phase 1: walk upward (library author flow)', () => {
|
|
34
|
+
it('finds fragments.json in the start directory', () => {
|
|
35
|
+
writeFileSync(join(root, 'fragments.json'), '{}');
|
|
36
|
+
|
|
37
|
+
const result = findFragmentsJson(root);
|
|
38
|
+
expect(result).toEqual([join(root, 'fragments.json')]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('finds fragments.json in a parent directory', () => {
|
|
42
|
+
const sub = join(root, 'packages', 'app');
|
|
43
|
+
mkdirSync(sub, { recursive: true });
|
|
44
|
+
writeFileSync(join(root, 'fragments.json'), '{}');
|
|
45
|
+
|
|
46
|
+
const result = findFragmentsJson(sub);
|
|
47
|
+
expect(result).toEqual([join(root, 'fragments.json')]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns empty array when no fragments.json exists anywhere', () => {
|
|
51
|
+
const sub = join(root, 'some', 'deep', 'path');
|
|
52
|
+
mkdirSync(sub, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const result = findFragmentsJson(sub);
|
|
55
|
+
expect(result).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('Phase 2: resolve deps with "fragments" field', () => {
|
|
60
|
+
it('finds fragments.json via dependency with "fragments" field', () => {
|
|
61
|
+
const depDir = join(root, 'node_modules', 'my-ui-lib');
|
|
62
|
+
mkdirSync(depDir, { recursive: true });
|
|
63
|
+
|
|
64
|
+
writeJson(join(root, 'package.json'), {
|
|
65
|
+
dependencies: { 'my-ui-lib': '^1.0.0' },
|
|
66
|
+
});
|
|
67
|
+
writeJson(join(depDir, 'package.json'), {
|
|
68
|
+
name: 'my-ui-lib',
|
|
69
|
+
fragments: 'fragments.json',
|
|
70
|
+
});
|
|
71
|
+
writeFileSync(join(depDir, 'fragments.json'), '{}');
|
|
72
|
+
|
|
73
|
+
const result = findFragmentsJson(root);
|
|
74
|
+
expect(result.length).toBe(1);
|
|
75
|
+
expect(result[0]).toBe(join(depDir, 'fragments.json'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('resolves custom fragments path from dependency', () => {
|
|
79
|
+
const depDir = join(root, 'node_modules', 'my-ui-lib');
|
|
80
|
+
mkdirSync(join(depDir, 'dist'), { recursive: true });
|
|
81
|
+
|
|
82
|
+
writeJson(join(root, 'package.json'), {
|
|
83
|
+
dependencies: { 'my-ui-lib': '^1.0.0' },
|
|
84
|
+
});
|
|
85
|
+
writeJson(join(depDir, 'package.json'), {
|
|
86
|
+
name: 'my-ui-lib',
|
|
87
|
+
fragments: 'dist/fragments.json',
|
|
88
|
+
});
|
|
89
|
+
writeFileSync(join(depDir, 'dist', 'fragments.json'), '{}');
|
|
90
|
+
|
|
91
|
+
const result = findFragmentsJson(root);
|
|
92
|
+
expect(result.length).toBe(1);
|
|
93
|
+
expect(result[0]).toBe(join(depDir, 'dist', 'fragments.json'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('finds fragments.json from devDependencies', () => {
|
|
97
|
+
const depDir = join(root, 'node_modules', 'my-ui-lib');
|
|
98
|
+
mkdirSync(depDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
writeJson(join(root, 'package.json'), {
|
|
101
|
+
devDependencies: { 'my-ui-lib': '^1.0.0' },
|
|
102
|
+
});
|
|
103
|
+
writeJson(join(depDir, 'package.json'), {
|
|
104
|
+
name: 'my-ui-lib',
|
|
105
|
+
fragments: 'fragments.json',
|
|
106
|
+
});
|
|
107
|
+
writeFileSync(join(depDir, 'fragments.json'), '{}');
|
|
108
|
+
|
|
109
|
+
const result = findFragmentsJson(root);
|
|
110
|
+
expect(result.length).toBe(1);
|
|
111
|
+
expect(result[0]).toBe(join(depDir, 'fragments.json'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('skips dependencies without "fragments" field', () => {
|
|
115
|
+
const depDir = join(root, 'node_modules', 'react');
|
|
116
|
+
mkdirSync(depDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
writeJson(join(root, 'package.json'), {
|
|
119
|
+
dependencies: { react: '^18.0.0' },
|
|
120
|
+
});
|
|
121
|
+
writeJson(join(depDir, 'package.json'), {
|
|
122
|
+
name: 'react',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = findFragmentsJson(root);
|
|
126
|
+
expect(result).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('skips dependency when fragments file does not exist', () => {
|
|
130
|
+
const depDir = join(root, 'node_modules', 'my-ui-lib');
|
|
131
|
+
mkdirSync(depDir, { recursive: true });
|
|
132
|
+
|
|
133
|
+
writeJson(join(root, 'package.json'), {
|
|
134
|
+
dependencies: { 'my-ui-lib': '^1.0.0' },
|
|
135
|
+
});
|
|
136
|
+
writeJson(join(depDir, 'package.json'), {
|
|
137
|
+
name: 'my-ui-lib',
|
|
138
|
+
fragments: 'fragments.json',
|
|
139
|
+
});
|
|
140
|
+
// Note: fragments.json not created
|
|
141
|
+
|
|
142
|
+
const result = findFragmentsJson(root);
|
|
143
|
+
expect(result).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('skips unresolvable dependencies gracefully', () => {
|
|
147
|
+
writeJson(join(root, 'package.json'), {
|
|
148
|
+
dependencies: { 'nonexistent-package': '^1.0.0' },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = findFragmentsJson(root);
|
|
152
|
+
expect(result).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('handles project with no package.json', () => {
|
|
156
|
+
const result = findFragmentsJson(root);
|
|
157
|
+
expect(result).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('symlink resolution (pnpm-style)', () => {
|
|
162
|
+
it('follows symlinks to find fragments.json', () => {
|
|
163
|
+
// Simulate pnpm layout: real package in .pnpm, symlink in node_modules
|
|
164
|
+
const realDir = join(root, '.pnpm', 'my-ui-lib@1.0.0', 'node_modules', 'my-ui-lib');
|
|
165
|
+
const symlinkDir = join(root, 'node_modules', 'my-ui-lib');
|
|
166
|
+
|
|
167
|
+
mkdirSync(realDir, { recursive: true });
|
|
168
|
+
mkdirSync(join(root, 'node_modules'), { recursive: true });
|
|
169
|
+
|
|
170
|
+
writeJson(join(realDir, 'package.json'), {
|
|
171
|
+
name: 'my-ui-lib',
|
|
172
|
+
fragments: 'fragments.json',
|
|
173
|
+
});
|
|
174
|
+
writeFileSync(join(realDir, 'fragments.json'), '{}');
|
|
175
|
+
|
|
176
|
+
// Create symlink: node_modules/my-ui-lib -> .pnpm/.../my-ui-lib
|
|
177
|
+
symlinkSync(realDir, symlinkDir, 'dir');
|
|
178
|
+
|
|
179
|
+
writeJson(join(root, 'package.json'), {
|
|
180
|
+
dependencies: { 'my-ui-lib': '^1.0.0' },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const result = findFragmentsJson(root);
|
|
184
|
+
expect(result.length).toBe(1);
|
|
185
|
+
// require.resolve follows the symlink to the real path
|
|
186
|
+
expect(result[0]).toBe(join(realDir, 'fragments.json'));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('hoisted dependencies', () => {
|
|
191
|
+
it('finds fragments.json from a hoisted parent node_modules', () => {
|
|
192
|
+
// Monorepo layout: deps hoisted to root, app is in packages/app
|
|
193
|
+
const appDir = join(root, 'packages', 'app');
|
|
194
|
+
const hoistedDep = join(root, 'node_modules', 'my-ui-lib');
|
|
195
|
+
|
|
196
|
+
mkdirSync(appDir, { recursive: true });
|
|
197
|
+
mkdirSync(hoistedDep, { recursive: true });
|
|
198
|
+
|
|
199
|
+
writeJson(join(appDir, 'package.json'), {
|
|
200
|
+
dependencies: { 'my-ui-lib': '^1.0.0' },
|
|
201
|
+
});
|
|
202
|
+
writeJson(join(hoistedDep, 'package.json'), {
|
|
203
|
+
name: 'my-ui-lib',
|
|
204
|
+
fragments: 'fragments.json',
|
|
205
|
+
});
|
|
206
|
+
writeFileSync(join(hoistedDep, 'fragments.json'), '{}');
|
|
207
|
+
|
|
208
|
+
// createRequire resolves up the directory tree, so this should work
|
|
209
|
+
const result = findFragmentsJson(appDir);
|
|
210
|
+
expect(result.length).toBe(1);
|
|
211
|
+
expect(result[0]).toBe(join(hoistedDep, 'fragments.json'));
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('deduplication', () => {
|
|
216
|
+
it('returns both local and dep fragments when they are different files', () => {
|
|
217
|
+
// fragments.json in root (found by walk-up) AND dep has its own
|
|
218
|
+
writeFileSync(join(root, 'fragments.json'), '{}');
|
|
219
|
+
|
|
220
|
+
const depDir = join(root, 'node_modules', 'my-ui-lib');
|
|
221
|
+
mkdirSync(depDir, { recursive: true });
|
|
222
|
+
writeJson(join(root, 'package.json'), {
|
|
223
|
+
dependencies: { 'my-ui-lib': '^1.0.0' },
|
|
224
|
+
});
|
|
225
|
+
writeJson(join(depDir, 'package.json'), {
|
|
226
|
+
name: 'my-ui-lib',
|
|
227
|
+
fragments: 'fragments.json',
|
|
228
|
+
});
|
|
229
|
+
writeFileSync(join(depDir, 'fragments.json'), '{}');
|
|
230
|
+
|
|
231
|
+
const result = findFragmentsJson(root);
|
|
232
|
+
// Walk-up finds root/fragments.json, dep resolution finds dep/fragments.json
|
|
233
|
+
expect(result.length).toBe(2);
|
|
234
|
+
expect(result).toContain(join(root, 'fragments.json'));
|
|
235
|
+
expect(result).toContain(join(depDir, 'fragments.json'));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('multiple dependencies', () => {
|
|
240
|
+
it('finds fragments.json from multiple dependencies', () => {
|
|
241
|
+
const dep1 = join(root, 'node_modules', 'ui-lib-a');
|
|
242
|
+
const dep2 = join(root, 'node_modules', 'ui-lib-b');
|
|
243
|
+
mkdirSync(dep1, { recursive: true });
|
|
244
|
+
mkdirSync(dep2, { recursive: true });
|
|
245
|
+
|
|
246
|
+
writeJson(join(root, 'package.json'), {
|
|
247
|
+
dependencies: { 'ui-lib-a': '^1.0.0' },
|
|
248
|
+
devDependencies: { 'ui-lib-b': '^2.0.0' },
|
|
249
|
+
});
|
|
250
|
+
writeJson(join(dep1, 'package.json'), {
|
|
251
|
+
name: 'ui-lib-a',
|
|
252
|
+
fragments: 'fragments.json',
|
|
253
|
+
});
|
|
254
|
+
writeFileSync(join(dep1, 'fragments.json'), '{}');
|
|
255
|
+
writeJson(join(dep2, 'package.json'), {
|
|
256
|
+
name: 'ui-lib-b',
|
|
257
|
+
fragments: 'fragments.json',
|
|
258
|
+
});
|
|
259
|
+
writeFileSync(join(dep2, 'fragments.json'), '{}');
|
|
260
|
+
|
|
261
|
+
const result = findFragmentsJson(root);
|
|
262
|
+
expect(result.length).toBe(2);
|
|
263
|
+
expect(result).toContain(join(dep1, 'fragments.json'));
|
|
264
|
+
expect(result).toContain(join(dep2, 'fragments.json'));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('only includes deps that have "fragments" field', () => {
|
|
268
|
+
const uiLib = join(root, 'node_modules', 'ui-lib');
|
|
269
|
+
const react = join(root, 'node_modules', 'react');
|
|
270
|
+
mkdirSync(uiLib, { recursive: true });
|
|
271
|
+
mkdirSync(react, { recursive: true });
|
|
272
|
+
|
|
273
|
+
writeJson(join(root, 'package.json'), {
|
|
274
|
+
dependencies: { react: '^18.0.0', 'ui-lib': '^1.0.0' },
|
|
275
|
+
});
|
|
276
|
+
writeJson(join(react, 'package.json'), { name: 'react' });
|
|
277
|
+
writeJson(join(uiLib, 'package.json'), {
|
|
278
|
+
name: 'ui-lib',
|
|
279
|
+
fragments: 'fragments.json',
|
|
280
|
+
});
|
|
281
|
+
writeFileSync(join(uiLib, 'fragments.json'), '{}');
|
|
282
|
+
|
|
283
|
+
const result = findFragmentsJson(root);
|
|
284
|
+
expect(result.length).toBe(1);
|
|
285
|
+
expect(result[0]).toBe(join(uiLib, 'fragments.json'));
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('scoped packages', () => {
|
|
290
|
+
it('resolves scoped package names like @scope/ui', () => {
|
|
291
|
+
const depDir = join(root, 'node_modules', '@my-scope', 'ui');
|
|
292
|
+
mkdirSync(depDir, { recursive: true });
|
|
293
|
+
|
|
294
|
+
writeJson(join(root, 'package.json'), {
|
|
295
|
+
dependencies: { '@my-scope/ui': '^1.0.0' },
|
|
296
|
+
});
|
|
297
|
+
writeJson(join(depDir, 'package.json'), {
|
|
298
|
+
name: '@my-scope/ui',
|
|
299
|
+
fragments: 'fragments.json',
|
|
300
|
+
});
|
|
301
|
+
writeFileSync(join(depDir, 'fragments.json'), '{}');
|
|
302
|
+
|
|
303
|
+
const result = findFragmentsJson(root);
|
|
304
|
+
expect(result.length).toBe(1);
|
|
305
|
+
expect(result[0]).toBe(join(depDir, 'fragments.json'));
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/mcp/server.ts
CHANGED
|
@@ -32,6 +32,7 @@ async function getService(): Promise<ServiceModule> {
|
|
|
32
32
|
import { readFile } from 'node:fs/promises';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import { join, dirname, resolve } from 'node:path';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
35
36
|
import { projectFields } from './utils.js';
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -65,6 +66,63 @@ function filterPlaceholders(items: string[] | undefined): string[] {
|
|
|
65
66
|
);
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Find fragments.json files:
|
|
71
|
+
* 1. Walk up from startDir (for library authors with a local build)
|
|
72
|
+
* 2. Read package.json deps and resolve packages with a "fragments" field
|
|
73
|
+
*
|
|
74
|
+
* Uses Node.js module resolution (createRequire) to handle all package
|
|
75
|
+
* managers: pnpm symlinks, yarn PnP, monorepo hoisting, etc.
|
|
76
|
+
*/
|
|
77
|
+
export function findFragmentsJson(startDir: string): string[] {
|
|
78
|
+
const found: string[] = [];
|
|
79
|
+
const resolvedStart = resolve(startDir);
|
|
80
|
+
|
|
81
|
+
// 1. Walk upward from startDir (library author flow)
|
|
82
|
+
let dir = resolvedStart;
|
|
83
|
+
while (true) {
|
|
84
|
+
const candidate = join(dir, BRAND.outFile);
|
|
85
|
+
if (existsSync(candidate)) {
|
|
86
|
+
found.push(candidate);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const parent = dirname(dir);
|
|
90
|
+
if (parent === dir) break;
|
|
91
|
+
dir = parent;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Read package.json and resolve deps with "fragments" field
|
|
95
|
+
const pkgJsonPath = join(resolvedStart, 'package.json');
|
|
96
|
+
if (existsSync(pkgJsonPath)) {
|
|
97
|
+
try {
|
|
98
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
99
|
+
const allDeps = {
|
|
100
|
+
...pkgJson.dependencies,
|
|
101
|
+
...pkgJson.devDependencies,
|
|
102
|
+
};
|
|
103
|
+
const localRequire = createRequire(join(resolvedStart, 'noop.js'));
|
|
104
|
+
for (const depName of Object.keys(allDeps)) {
|
|
105
|
+
try {
|
|
106
|
+
const depPkgPath = localRequire.resolve(`${depName}/package.json`);
|
|
107
|
+
const depPkg = JSON.parse(readFileSync(depPkgPath, 'utf-8'));
|
|
108
|
+
if (depPkg.fragments) {
|
|
109
|
+
const fragmentsPath = join(dirname(depPkgPath), depPkg.fragments);
|
|
110
|
+
if (existsSync(fragmentsPath) && !found.includes(fragmentsPath)) {
|
|
111
|
+
found.push(fragmentsPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Package not resolvable or unreadable, skip
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// No package.json or unreadable
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
68
126
|
/**
|
|
69
127
|
* MCP Server configuration
|
|
70
128
|
*/
|
|
@@ -276,66 +334,12 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
276
334
|
// Lazy-loaded resources
|
|
277
335
|
let segmentsData: CompiledSegmentsFile | null = null;
|
|
278
336
|
let packageName: string | null = null;
|
|
279
|
-
|
|
337
|
+
|
|
280
338
|
let browserPool: any = null;
|
|
281
339
|
let storageManager: any = null;
|
|
282
340
|
let diffEngine: any = null;
|
|
283
341
|
let isPoolWarming = false;
|
|
284
342
|
|
|
285
|
-
/**
|
|
286
|
-
* Find fragments.json files:
|
|
287
|
-
* 1. Walk up from projectRoot (for library authors with a local build)
|
|
288
|
-
* 2. Read package.json deps and resolve packages with a "fragments" field
|
|
289
|
-
*/
|
|
290
|
-
function findFragmentsJson(startDir: string): string[] {
|
|
291
|
-
const found: string[] = [];
|
|
292
|
-
const resolvedStart = resolve(startDir);
|
|
293
|
-
|
|
294
|
-
// 1. Walk upward from startDir (library author flow)
|
|
295
|
-
let dir = resolvedStart;
|
|
296
|
-
while (true) {
|
|
297
|
-
const candidate = join(dir, BRAND.outFile);
|
|
298
|
-
if (existsSync(candidate)) {
|
|
299
|
-
found.push(candidate);
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
const parent = dirname(dir);
|
|
303
|
-
if (parent === dir) break;
|
|
304
|
-
dir = parent;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// 2. Read package.json and resolve deps with "fragments" field
|
|
308
|
-
const pkgJsonPath = join(resolvedStart, 'package.json');
|
|
309
|
-
if (existsSync(pkgJsonPath)) {
|
|
310
|
-
try {
|
|
311
|
-
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
312
|
-
const allDeps = {
|
|
313
|
-
...pkgJson.dependencies,
|
|
314
|
-
...pkgJson.devDependencies,
|
|
315
|
-
};
|
|
316
|
-
for (const depName of Object.keys(allDeps)) {
|
|
317
|
-
const depPkgPath = join(resolvedStart, 'node_modules', depName, 'package.json');
|
|
318
|
-
if (!existsSync(depPkgPath)) continue;
|
|
319
|
-
try {
|
|
320
|
-
const depPkg = JSON.parse(readFileSync(depPkgPath, 'utf-8'));
|
|
321
|
-
if (depPkg.fragments) {
|
|
322
|
-
const fragmentsPath = join(resolvedStart, 'node_modules', depName, depPkg.fragments);
|
|
323
|
-
if (existsSync(fragmentsPath) && !found.includes(fragmentsPath)) {
|
|
324
|
-
found.push(fragmentsPath);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
} catch {
|
|
328
|
-
// Skip unreadable package
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
} catch {
|
|
332
|
-
// No package.json or unreadable
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return found;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
343
|
async function loadSegments(): Promise<CompiledSegmentsFile> {
|
|
340
344
|
if (segmentsData) {
|
|
341
345
|
return segmentsData;
|
|
@@ -139,7 +139,7 @@ function extractJSDoc(
|
|
|
139
139
|
|
|
140
140
|
// Parse JSDoc content
|
|
141
141
|
const lines = content.split("\n");
|
|
142
|
-
|
|
142
|
+
const currentDescription: string[] = [];
|
|
143
143
|
let currentTag: string | null = null;
|
|
144
144
|
let currentTagContent: string[] = [];
|
|
145
145
|
|
|
@@ -197,7 +197,7 @@ function processTag(
|
|
|
197
197
|
if (propMatch) {
|
|
198
198
|
const [, type, nameRaw, description] = propMatch;
|
|
199
199
|
const isOptional = nameRaw.startsWith("[");
|
|
200
|
-
const name = nameRaw.
|
|
200
|
+
const name = nameRaw.replaceAll("[", "").replaceAll("]", "");
|
|
201
201
|
docs.props?.push({
|
|
202
202
|
name,
|
|
203
203
|
type,
|
|
@@ -331,7 +331,7 @@ function parseJSDocContent(
|
|
|
331
331
|
}
|
|
332
332
|
): void {
|
|
333
333
|
const lines = content.split("\n");
|
|
334
|
-
|
|
334
|
+
const currentDescription: string[] = [];
|
|
335
335
|
let currentTag: string | null = null;
|
|
336
336
|
let currentTagContent: string[] = [];
|
|
337
337
|
|
|
@@ -484,7 +484,7 @@ function camelToTitle(str: string): string {
|
|
|
484
484
|
function inferComponentFromPath(filePath: string): string {
|
|
485
485
|
const fileName = basename(filePath);
|
|
486
486
|
// Remove .stories.tsx etc
|
|
487
|
-
|
|
487
|
+
const name = fileName.replace(/\.stories\.(tsx?|jsx?|mdx?)$/, "");
|
|
488
488
|
// Handle patterns like Button.stories.tsx
|
|
489
489
|
return name;
|
|
490
490
|
}
|
package/src/service/figma.ts
CHANGED
|
@@ -313,7 +313,7 @@ export class FigmaClient {
|
|
|
313
313
|
*/
|
|
314
314
|
parseUrl(url: string): FigmaUrlParts {
|
|
315
315
|
// Match both /file/ and /design/ paths
|
|
316
|
-
const urlPattern = /figma\.com\/(?:file|design)\/([
|
|
316
|
+
const urlPattern = /figma\.com\/(?:file|design)\/([^/]+)\/[^?]*\?.*node-id=([^&]+)/i;
|
|
317
317
|
const match = url.match(urlPattern);
|
|
318
318
|
|
|
319
319
|
if (!match) {
|
|
@@ -410,7 +410,7 @@ export class FigmaClient {
|
|
|
410
410
|
*/
|
|
411
411
|
parseFileUrl(url: string): { fileKey: string; nodeId?: string } {
|
|
412
412
|
// Match both /file/ and /design/ paths
|
|
413
|
-
const urlPattern = /figma\.com\/(?:file|design)\/([
|
|
413
|
+
const urlPattern = /figma\.com\/(?:file|design)\/([^/]+)/i;
|
|
414
414
|
const match = url.match(urlPattern);
|
|
415
415
|
|
|
416
416
|
if (!match) {
|
|
@@ -200,12 +200,13 @@ export class MetricsStore {
|
|
|
200
200
|
let key: string;
|
|
201
201
|
|
|
202
202
|
switch (groupBy) {
|
|
203
|
-
case "week":
|
|
203
|
+
case "week": {
|
|
204
204
|
// Get ISO week
|
|
205
205
|
const weekStart = new Date(date);
|
|
206
206
|
weekStart.setDate(date.getDate() - date.getDay());
|
|
207
207
|
key = weekStart.toISOString().split("T")[0];
|
|
208
208
|
break;
|
|
209
|
+
}
|
|
209
210
|
case "month":
|
|
210
211
|
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
211
212
|
break;
|
|
@@ -189,13 +189,14 @@ function generateHunk(
|
|
|
189
189
|
|
|
190
190
|
// The hunk format depends on the style type
|
|
191
191
|
switch (styleType) {
|
|
192
|
-
case "inline":
|
|
192
|
+
case "inline": {
|
|
193
193
|
// React inline style: { backgroundColor: '#0051c2' }
|
|
194
194
|
const camelProp = toCamelCase(cssProperty);
|
|
195
195
|
lines.push(`@@ -1,1 +1,1 @@ inline style`);
|
|
196
196
|
lines.push(`- ${camelProp}: '${currentValue}',`);
|
|
197
197
|
lines.push(`+ ${camelProp}: 'var(${suggestedFix.tokenName})',`);
|
|
198
198
|
break;
|
|
199
|
+
}
|
|
199
200
|
|
|
200
201
|
case "styled-components":
|
|
201
202
|
case "emotion":
|
|
@@ -176,11 +176,15 @@ function generateTestCaseXml(result: TestResult): string {
|
|
|
176
176
|
* Escape special XML characters
|
|
177
177
|
*/
|
|
178
178
|
function escapeXml(str: string): string {
|
|
179
|
-
|
|
179
|
+
const cleaned = Array.from(str).filter((ch) => {
|
|
180
|
+
const code = ch.charCodeAt(0);
|
|
181
|
+
return code === 0x09 || code === 0x0A || code === 0x0D || code >= 0x20;
|
|
182
|
+
}).join('');
|
|
183
|
+
|
|
184
|
+
return cleaned
|
|
180
185
|
.replace(/&/g, '&')
|
|
181
186
|
.replace(/</g, '<')
|
|
182
187
|
.replace(/>/g, '>')
|
|
183
188
|
.replace(/"/g, '"')
|
|
184
|
-
.replace(/'/g, ''')
|
|
185
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); // Remove invalid XML characters
|
|
189
|
+
.replace(/'/g, ''');
|
|
186
190
|
}
|