@fragments-sdk/cli 0.7.3 → 0.7.5

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.
Files changed (70) hide show
  1. package/LICENSE +1 -4
  2. package/README.md +2 -0
  3. package/dist/bin.js +39 -16
  4. package/dist/bin.js.map +1 -1
  5. package/dist/{chunk-D34Q6A7S.js → chunk-AWYCDRPG.js} +8 -2
  6. package/dist/chunk-AWYCDRPG.js.map +1 -0
  7. package/dist/{chunk-R2YH7NLN.js → chunk-CR3XHBGM.js} +3 -3
  8. package/dist/{chunk-QPY4DUFB.js → chunk-EFQ7SIBX.js} +583 -108
  9. package/dist/chunk-EFQ7SIBX.js.map +1 -0
  10. package/dist/{chunk-UXLGIGSX.js → chunk-GIC3I2KZ.js} +2 -2
  11. package/dist/{chunk-R6IZZSE7.js → chunk-JZNATKQA.js} +9 -3
  12. package/dist/chunk-JZNATKQA.js.map +1 -0
  13. package/dist/{chunk-P33AKQJW.js → chunk-SFWZ4K7C.js} +8 -2
  14. package/dist/{chunk-P33AKQJW.js.map → chunk-SFWZ4K7C.js.map} +1 -1
  15. package/dist/{core-3NMNCLFW.js → core-T7BDYEGO.js} +3 -3
  16. package/dist/{discovery-AKGA6CJD.js → discovery-Z4RDDFVR.js} +2 -2
  17. package/dist/{generate-JAUEHKK7.js → generate-C2DKFCFJ.js} +5 -5
  18. package/dist/index.d.ts +28 -2
  19. package/dist/index.js +9 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/{init-DZQOT54X.js → init-O3FCHEPN.js} +26 -8
  22. package/dist/init-O3FCHEPN.js.map +1 -0
  23. package/dist/mcp-bin.js +3 -3
  24. package/dist/{scan-OJRCVKK2.js → scan-IYTZDUKG.js} +6 -6
  25. package/dist/{service-CFFBHW4X.js → service-VA6XKADO.js} +3 -3
  26. package/dist/{static-viewer-VA2JXSCX.js → static-viewer-5N42MBDR.js} +3 -3
  27. package/dist/{test-O7DZNKDC.js → test-OMMDWL2W.js} +4 -4
  28. package/dist/{tokens-N7THFD6J.js → tokens-6VJAHFIG.js} +5 -5
  29. package/dist/{viewer-QTR7QJMM.js → viewer-IVP5XC7U.js} +37 -17
  30. package/dist/viewer-IVP5XC7U.js.map +1 -0
  31. package/package.json +8 -2
  32. package/src/bin.ts +4 -0
  33. package/src/commands/add.ts +6 -0
  34. package/src/commands/init.ts +24 -4
  35. package/src/commands/validate.ts +24 -2
  36. package/src/core/config.ts +6 -0
  37. package/src/core/discovery.ts +7 -1
  38. package/src/core/index.ts +1 -0
  39. package/src/core/schema.ts +6 -0
  40. package/src/core/types.ts +21 -0
  41. package/src/index.ts +2 -1
  42. package/src/migrate/detect.ts +4 -0
  43. package/src/service/snippet-validation.test.ts +209 -0
  44. package/src/service/snippet-validation.ts +635 -0
  45. package/src/validators.ts +53 -5
  46. package/src/viewer/__tests__/viewer-integration.test.ts +8 -0
  47. package/src/viewer/components/App.tsx +63 -2
  48. package/src/viewer/components/CodePanel.naming.test.tsx +60 -0
  49. package/src/viewer/components/CodePanel.tsx +76 -468
  50. package/src/viewer/components/Layout.tsx +2 -2
  51. package/src/viewer/components/LeftSidebar.tsx +35 -77
  52. package/src/viewer/preview-frame.html +1 -1
  53. package/src/viewer/styles/globals.css +2 -1
  54. package/src/viewer/utils/a11y-fixes.ts +24 -9
  55. package/src/viewer/vite-plugin.ts +27 -2
  56. package/dist/chunk-D34Q6A7S.js.map +0 -1
  57. package/dist/chunk-QPY4DUFB.js.map +0 -1
  58. package/dist/chunk-R6IZZSE7.js.map +0 -1
  59. package/dist/init-DZQOT54X.js.map +0 -1
  60. package/dist/viewer-QTR7QJMM.js.map +0 -1
  61. /package/dist/{chunk-R2YH7NLN.js.map → chunk-CR3XHBGM.js.map} +0 -0
  62. /package/dist/{chunk-UXLGIGSX.js.map → chunk-GIC3I2KZ.js.map} +0 -0
  63. /package/dist/{core-3NMNCLFW.js.map → core-T7BDYEGO.js.map} +0 -0
  64. /package/dist/{discovery-AKGA6CJD.js.map → discovery-Z4RDDFVR.js.map} +0 -0
  65. /package/dist/{generate-JAUEHKK7.js.map → generate-C2DKFCFJ.js.map} +0 -0
  66. /package/dist/{scan-OJRCVKK2.js.map → scan-IYTZDUKG.js.map} +0 -0
  67. /package/dist/{service-CFFBHW4X.js.map → service-VA6XKADO.js.map} +0 -0
  68. /package/dist/{static-viewer-VA2JXSCX.js.map → static-viewer-5N42MBDR.js.map} +0 -0
  69. /package/dist/{test-O7DZNKDC.js.map → test-OMMDWL2W.js.map} +0 -0
  70. /package/dist/{tokens-N7THFD6J.js.map → tokens-6VJAHFIG.js.map} +0 -0
@@ -62,9 +62,15 @@ export async function discoverFragmentFiles(
62
62
  config: FragmentsConfig,
63
63
  configDir: string
64
64
  ): Promise<DiscoveredFile[]> {
65
+ const defaultExcludes = [
66
+ '**/*.test.stories.*',
67
+ '**/*.stories.test.*',
68
+ '**/*.test.story.*',
69
+ '**/*.story.test.*',
70
+ ];
65
71
  const files = await fg(config.include, {
66
72
  cwd: configDir,
67
- ignore: config.exclude ?? [],
73
+ ignore: [...defaultExcludes, ...(config.exclude ?? [])],
68
74
  absolute: false,
69
75
  });
70
76
 
package/src/core/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  FragmentVariant,
24
24
  FragmentDefinition,
25
25
  FragmentsConfig,
26
+ SnippetPolicyConfig,
26
27
  RegistryOptions,
27
28
  CompiledFragment,
28
29
  CompiledFragmentsFile,
@@ -192,6 +192,12 @@ export const fragmentsConfigSchema = z.object({
192
192
  tokens: z.object({
193
193
  include: z.array(z.string()).min(1),
194
194
  }).passthrough().optional(),
195
+ snippets: z.object({
196
+ mode: z.enum(['warn', 'error']).optional(),
197
+ scope: z.enum(['snippet', 'snippet+render']).optional(),
198
+ requireFullSnippet: z.boolean().optional(),
199
+ allowedExternalModules: z.array(z.string().min(1)).optional(),
200
+ }).optional(),
195
201
  });
196
202
 
197
203
  /**
package/src/core/types.ts CHANGED
@@ -426,6 +426,24 @@ export interface CIConfig {
426
426
  jsonOutput?: boolean;
427
427
  }
428
428
 
429
+ /**
430
+ * Snippet policy configuration.
431
+ * Controls snippet/render quality enforcement in `fragments validate`.
432
+ */
433
+ export interface SnippetPolicyConfig {
434
+ /** Validation mode: warn (non-blocking) or error (blocking). Default: warn */
435
+ mode?: "warn" | "error";
436
+
437
+ /** Validate snippet strings only, or snippet strings + render functions. Default: snippet+render */
438
+ scope?: "snippet" | "snippet+render";
439
+
440
+ /** Require authored snippets to be full, copy-pasteable examples with imports. Default: true */
441
+ requireFullSnippet?: boolean;
442
+
443
+ /** Allow these external modules for JSX components in snippets/renders. */
444
+ allowedExternalModules?: string[];
445
+ }
446
+
429
447
  /**
430
448
  * Config file structure
431
449
  */
@@ -465,6 +483,9 @@ export interface FragmentsConfig {
465
483
 
466
484
  /** CI pipeline configuration */
467
485
  ci?: CIConfig;
486
+
487
+ /** Snippet/render policy validation */
488
+ snippets?: SnippetPolicyConfig;
468
489
  }
469
490
 
470
491
  /**
package/src/index.ts CHANGED
@@ -9,11 +9,12 @@ export {
9
9
  export type { DiscoveredFile } from "./core/node.js";
10
10
 
11
11
  // Validators
12
- export { validateSchema, validateCoverage, validateAll } from "./validators.js";
12
+ export { validateSchema, validateCoverage, validateAll, validateSnippets } from "./validators.js";
13
13
  export type {
14
14
  ValidationResult,
15
15
  ValidationError,
16
16
  ValidationWarning,
17
+ ValidationRunOptions,
17
18
  } from "./validators.js";
18
19
 
19
20
  // Build
@@ -164,6 +164,10 @@ export async function discoverStoryFiles(
164
164
  "**/dist/**",
165
165
  "**/build/**",
166
166
  "**/.storybook/**",
167
+ "**/*.test.stories.*",
168
+ "**/*.stories.test.*",
169
+ "**/*.test.story.*",
170
+ "**/*.story.test.*",
167
171
  ],
168
172
  });
169
173
 
@@ -0,0 +1,209 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import type { FragmentsConfig } from '../core/types.js';
6
+ import { validateSnippetPolicy } from './snippet-validation.js';
7
+
8
+ async function createTempProject(): Promise<string> {
9
+ return mkdtemp(resolve(tmpdir(), 'fragments-snippets-'));
10
+ }
11
+
12
+ async function writeFragment(projectDir: string, componentName: string, content: string): Promise<void> {
13
+ const dir = resolve(projectDir, 'src/components', componentName);
14
+ await mkdir(dir, { recursive: true });
15
+ await writeFile(resolve(dir, `${componentName}.fragment.tsx`), content);
16
+ }
17
+
18
+ const baseConfig: FragmentsConfig = {
19
+ include: ['src/**/*.fragment.tsx'],
20
+ exclude: ['**/node_modules/**'],
21
+ framework: 'react',
22
+ snippets: {
23
+ mode: 'warn',
24
+ scope: 'snippet+render',
25
+ requireFullSnippet: true,
26
+ allowedExternalModules: ['@phosphor-icons/react'],
27
+ },
28
+ };
29
+
30
+ let lastProjectDir: string | null = null;
31
+
32
+ afterEach(async () => {
33
+ if (lastProjectDir) {
34
+ await rm(lastProjectDir, { recursive: true, force: true });
35
+ lastProjectDir = null;
36
+ }
37
+ });
38
+
39
+ describe('validateSnippetPolicy', () => {
40
+ it('reports snippet and render violations in warn mode', async () => {
41
+ const projectDir = await createTempProject();
42
+ lastProjectDir = projectDir;
43
+
44
+ await writeFragment(
45
+ projectDir,
46
+ 'Button',
47
+ `import React from 'react';
48
+ import { defineFragment } from '@fragments/core';
49
+ import { Button } from '.';
50
+
51
+ export default defineFragment({
52
+ component: Button,
53
+ meta: { name: 'Button', description: 'Button', category: 'actions' },
54
+ usage: { when: ['x'], whenNot: ['y'] },
55
+ props: {},
56
+ variants: [
57
+ {
58
+ name: 'Default',
59
+ description: 'Default',
60
+ code: '<Button />',
61
+ render: () => (
62
+ <div style={{ display: 'flex' }}>
63
+ <Button>Click</Button>
64
+ </div>
65
+ ),
66
+ },
67
+ ],
68
+ });
69
+ `,
70
+ );
71
+
72
+ const result = await validateSnippetPolicy(baseConfig, projectDir);
73
+
74
+ expect(result.errors).toHaveLength(0);
75
+ expect(result.warnings.length).toBeGreaterThan(0);
76
+ expect(result.warnings.some((w) => w.message.includes('full snippet required (missing import statement)'))).toBe(true);
77
+ expect(result.warnings.some((w) => w.message.includes('inline style usage is not allowed'))).toBe(true);
78
+ expect(result.warnings.some((w) => w.message.includes('raw HTML tags are not allowed'))).toBe(true);
79
+ });
80
+
81
+ it('allows approved external JSX modules in snippets', async () => {
82
+ const projectDir = await createTempProject();
83
+ lastProjectDir = projectDir;
84
+
85
+ await writeFragment(
86
+ projectDir,
87
+ 'Icon',
88
+ `import React from 'react';
89
+ import { defineFragment } from '@fragments/core';
90
+ import { Icon } from '.';
91
+
92
+ export default defineFragment({
93
+ component: Icon,
94
+ meta: { name: 'Icon', description: 'Icon', category: 'display' },
95
+ usage: { when: ['x'], whenNot: ['y'] },
96
+ props: {},
97
+ variants: [
98
+ {
99
+ name: 'Default',
100
+ description: 'Default',
101
+ code: \`import { Icon } from '@fragments-sdk/ui';
102
+ import { House } from '@phosphor-icons/react';
103
+
104
+ <Icon icon={House} />
105
+ <House />\`,
106
+ render: () => <Icon icon={() => null} />,
107
+ },
108
+ ],
109
+ });
110
+ `,
111
+ );
112
+
113
+ const result = await validateSnippetPolicy(baseConfig, projectDir);
114
+
115
+ expect(result.errors).toHaveLength(0);
116
+ expect(result.warnings).toHaveLength(0);
117
+ });
118
+
119
+ it('does not treat PascalCase component names like Header as intrinsic html tags', async () => {
120
+ const projectDir = await createTempProject();
121
+ lastProjectDir = projectDir;
122
+
123
+ await writeFragment(
124
+ projectDir,
125
+ 'Header',
126
+ `import React from 'react';
127
+ import { defineFragment } from '@fragments/core';
128
+ import { Header } from '.';
129
+
130
+ export default defineFragment({
131
+ component: Header,
132
+ meta: { name: 'Header', description: 'Header', category: 'layout' },
133
+ usage: { when: ['x'], whenNot: ['y'] },
134
+ props: {},
135
+ variants: [
136
+ {
137
+ name: 'Default',
138
+ description: 'Default',
139
+ code: \`import { Header } from '@/components/Header';
140
+
141
+ <Header>
142
+ <Header.Brand>MyApp</Header.Brand>
143
+ </Header>\`,
144
+ render: () => (
145
+ <Header>
146
+ <Header.Brand>MyApp</Header.Brand>
147
+ </Header>
148
+ ),
149
+ },
150
+ ],
151
+ });
152
+ `,
153
+ );
154
+
155
+ const result = await validateSnippetPolicy(baseConfig, projectDir);
156
+
157
+ expect(result.errors).toHaveLength(0);
158
+ expect(result.warnings).toHaveLength(0);
159
+ });
160
+
161
+ it('supports error mode and alphabetical component batch filtering', async () => {
162
+ const projectDir = await createTempProject();
163
+ lastProjectDir = projectDir;
164
+
165
+ await writeFragment(
166
+ projectDir,
167
+ 'Alpha',
168
+ `import React from 'react';
169
+ import { defineFragment } from '@fragments/core';
170
+ import { Alpha } from '.';
171
+
172
+ export default defineFragment({
173
+ component: Alpha,
174
+ meta: { name: 'Alpha', description: 'Alpha', category: 'display' },
175
+ usage: { when: ['x'], whenNot: ['y'] },
176
+ props: {},
177
+ variants: [{ name: 'Default', description: 'Default', render: () => <Alpha /> }],
178
+ });
179
+ `,
180
+ );
181
+
182
+ await writeFragment(
183
+ projectDir,
184
+ 'Beta',
185
+ `import React from 'react';
186
+ import { defineFragment } from '@fragments/core';
187
+ import { Beta } from '.';
188
+
189
+ export default defineFragment({
190
+ component: Beta,
191
+ meta: { name: 'Beta', description: 'Beta', category: 'display' },
192
+ usage: { when: ['x'], whenNot: ['y'] },
193
+ props: {},
194
+ variants: [{ name: 'Default', description: 'Default', render: () => <Beta /> }],
195
+ });
196
+ `,
197
+ );
198
+
199
+ const result = await validateSnippetPolicy(baseConfig, projectDir, {
200
+ mode: 'error',
201
+ componentStart: 'Beta',
202
+ componentLimit: 1,
203
+ });
204
+
205
+ expect(result.warnings).toHaveLength(0);
206
+ expect(result.errors.length).toBeGreaterThan(0);
207
+ expect(result.errors.every((issue) => issue.file.includes('Beta.fragment.tsx'))).toBe(true);
208
+ });
209
+ });