@fragments-sdk/core 0.1.0
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/LICENSE +84 -0
- package/dist/index.d.ts +2873 -0
- package/dist/index.js +1431 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
- package/src/__tests__/preview-runtime.test.tsx +111 -0
- package/src/composition.test.ts +262 -0
- package/src/composition.ts +318 -0
- package/src/constants.ts +114 -0
- package/src/context.ts +2 -0
- package/src/defineFragment.ts +141 -0
- package/src/figma.ts +263 -0
- package/src/fragment-types.ts +214 -0
- package/src/index.ts +207 -0
- package/src/performance-presets.ts +142 -0
- package/src/preview-runtime.tsx +144 -0
- package/src/schema.ts +229 -0
- package/src/storyAdapter.test.ts +571 -0
- package/src/storyAdapter.ts +761 -0
- package/src/storyFilters.test.ts +350 -0
- package/src/storyFilters.ts +253 -0
- package/src/storybook-csf.ts +11 -0
- package/src/token-parser.ts +321 -0
- package/src/token-types.ts +287 -0
- package/src/types.ts +784 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Storybook adapter filtering.
|
|
3
|
+
* Tests per-file heuristics, cross-file sub-component detection, and config precedence.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
checkStoryExclusion,
|
|
9
|
+
detectSubComponentPaths,
|
|
10
|
+
isForceIncluded,
|
|
11
|
+
isConfigExcluded,
|
|
12
|
+
type ExclusionResult,
|
|
13
|
+
} from './storyFilters.js';
|
|
14
|
+
import type { StorybookFilterConfig } from './types.js';
|
|
15
|
+
|
|
16
|
+
const DEFAULTS: StorybookFilterConfig = {
|
|
17
|
+
excludeDeprecated: true,
|
|
18
|
+
excludeTests: true,
|
|
19
|
+
excludeSvgIcons: true,
|
|
20
|
+
excludeSubComponents: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// detectSubComponentPaths
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe('detectSubComponentPaths', () => {
|
|
28
|
+
it('identifies sub-components when one story matches the directory name', () => {
|
|
29
|
+
const files = [
|
|
30
|
+
{ relativePath: 'src/components/Form/Form.stories.tsx' },
|
|
31
|
+
{ relativePath: 'src/components/Form/Checkbox.stories.tsx' },
|
|
32
|
+
{ relativePath: 'src/components/Form/RadioGroup.stories.tsx' },
|
|
33
|
+
];
|
|
34
|
+
const result = detectSubComponentPaths(files);
|
|
35
|
+
|
|
36
|
+
expect(result.size).toBe(2);
|
|
37
|
+
expect(result.get('src/components/Form/Checkbox.stories.tsx')).toBe('Form');
|
|
38
|
+
expect(result.get('src/components/Form/RadioGroup.stories.tsx')).toBe('Form');
|
|
39
|
+
expect(result.has('src/components/Form/Form.stories.tsx')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('treats story in its own sub-directory as standalone', () => {
|
|
43
|
+
const files = [
|
|
44
|
+
{ relativePath: 'src/components/Form/Form.stories.tsx' },
|
|
45
|
+
{ relativePath: 'src/components/Button/Button.stories.tsx' },
|
|
46
|
+
];
|
|
47
|
+
const result = detectSubComponentPaths(files);
|
|
48
|
+
expect(result.size).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps single story in a directory as standalone', () => {
|
|
52
|
+
const files = [
|
|
53
|
+
{ relativePath: 'src/components/Button/Button.stories.tsx' },
|
|
54
|
+
];
|
|
55
|
+
const result = detectSubComponentPaths(files);
|
|
56
|
+
expect(result.size).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps all stories when no story matches the directory name', () => {
|
|
60
|
+
const files = [
|
|
61
|
+
{ relativePath: 'src/components/Inputs/Checkbox.stories.tsx' },
|
|
62
|
+
{ relativePath: 'src/components/Inputs/RadioGroup.stories.tsx' },
|
|
63
|
+
{ relativePath: 'src/components/Inputs/Toggle.stories.tsx' },
|
|
64
|
+
];
|
|
65
|
+
const result = detectSubComponentPaths(files);
|
|
66
|
+
expect(result.size).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('detects three stories with one primary → two sub-components', () => {
|
|
70
|
+
const files = [
|
|
71
|
+
{ relativePath: 'src/components/Table/Table.stories.tsx' },
|
|
72
|
+
{ relativePath: 'src/components/Table/TableHeader.stories.tsx' },
|
|
73
|
+
{ relativePath: 'src/components/Table/TableRow.stories.tsx' },
|
|
74
|
+
];
|
|
75
|
+
const result = detectSubComponentPaths(files);
|
|
76
|
+
|
|
77
|
+
expect(result.size).toBe(2);
|
|
78
|
+
expect(result.get('src/components/Table/TableHeader.stories.tsx')).toBe('Table');
|
|
79
|
+
expect(result.get('src/components/Table/TableRow.stories.tsx')).toBe('Table');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ignores non-story file patterns', () => {
|
|
83
|
+
const files = [
|
|
84
|
+
{ relativePath: 'src/components/Form/Form.fragment.tsx' },
|
|
85
|
+
{ relativePath: 'src/components/Form/Checkbox.fragment.tsx' },
|
|
86
|
+
];
|
|
87
|
+
const result = detectSubComponentPaths(files);
|
|
88
|
+
expect(result.size).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// checkStoryExclusion
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('checkStoryExclusion', () => {
|
|
97
|
+
const base = {
|
|
98
|
+
componentName: 'Button',
|
|
99
|
+
variantCount: 3,
|
|
100
|
+
filePath: 'src/components/Button/Button.stories.tsx',
|
|
101
|
+
config: DEFAULTS,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
it('keeps a normal component', () => {
|
|
105
|
+
const result = checkStoryExclusion({
|
|
106
|
+
...base,
|
|
107
|
+
storybookTitle: 'Components/Actions/Button',
|
|
108
|
+
});
|
|
109
|
+
expect(result.excluded).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('excludes deprecated titles', () => {
|
|
113
|
+
const result = checkStoryExclusion({
|
|
114
|
+
...base,
|
|
115
|
+
storybookTitle: 'Deprecated/OldButton',
|
|
116
|
+
});
|
|
117
|
+
expect(result).toEqual<ExclusionResult>({
|
|
118
|
+
excluded: true,
|
|
119
|
+
reason: 'deprecated',
|
|
120
|
+
detail: expect.stringContaining('Deprecated'),
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('excludes titles ending with /test', () => {
|
|
125
|
+
const result = checkStoryExclusion({
|
|
126
|
+
...base,
|
|
127
|
+
storybookTitle: 'Components/Button/Test',
|
|
128
|
+
});
|
|
129
|
+
expect(result.excluded).toBe(true);
|
|
130
|
+
expect(result.reason).toBe('test-story');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('excludes titles ending with /tests', () => {
|
|
134
|
+
const result = checkStoryExclusion({
|
|
135
|
+
...base,
|
|
136
|
+
storybookTitle: 'Components/Button/Tests',
|
|
137
|
+
});
|
|
138
|
+
expect(result.excluded).toBe(true);
|
|
139
|
+
expect(result.reason).toBe('test-story');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('excludes *.test.stories.* file paths', () => {
|
|
143
|
+
const result = checkStoryExclusion({
|
|
144
|
+
...base,
|
|
145
|
+
filePath: 'src/components/Button/Button.test.stories.tsx',
|
|
146
|
+
});
|
|
147
|
+
expect(result.excluded).toBe(true);
|
|
148
|
+
expect(result.reason).toBe('test-story');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('excludes SVG icons by displayName', () => {
|
|
152
|
+
const result = checkStoryExclusion({
|
|
153
|
+
...base,
|
|
154
|
+
componentName: 'HomeLarge',
|
|
155
|
+
componentDisplayName: 'SvgHomeLarge',
|
|
156
|
+
});
|
|
157
|
+
expect(result.excluded).toBe(true);
|
|
158
|
+
expect(result.reason).toBe('svg-icon');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('excludes SVG icons by function name', () => {
|
|
162
|
+
const result = checkStoryExclusion({
|
|
163
|
+
...base,
|
|
164
|
+
componentName: 'HomeLarge',
|
|
165
|
+
componentFunctionName: 'SvgHomeLarge',
|
|
166
|
+
});
|
|
167
|
+
expect(result.excluded).toBe(true);
|
|
168
|
+
expect(result.reason).toBe('svg-icon');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('excludes SVG icons by component name', () => {
|
|
172
|
+
const result = checkStoryExclusion({
|
|
173
|
+
...base,
|
|
174
|
+
componentName: 'SvgArrowRight',
|
|
175
|
+
});
|
|
176
|
+
expect(result.excluded).toBe(true);
|
|
177
|
+
expect(result.reason).toBe('svg-icon');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('excludes hidden-tagged stories', () => {
|
|
181
|
+
const result = checkStoryExclusion({
|
|
182
|
+
...base,
|
|
183
|
+
tags: ['hidden'],
|
|
184
|
+
});
|
|
185
|
+
expect(result.excluded).toBe(true);
|
|
186
|
+
expect(result.reason).toBe('tag-excluded');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('excludes internal-tagged stories', () => {
|
|
190
|
+
const result = checkStoryExclusion({
|
|
191
|
+
...base,
|
|
192
|
+
tags: ['internal'],
|
|
193
|
+
});
|
|
194
|
+
expect(result.excluded).toBe(true);
|
|
195
|
+
expect(result.reason).toBe('tag-excluded');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('excludes no-fragment-tagged stories', () => {
|
|
199
|
+
const result = checkStoryExclusion({
|
|
200
|
+
...base,
|
|
201
|
+
tags: ['no-fragment'],
|
|
202
|
+
});
|
|
203
|
+
expect(result.excluded).toBe(true);
|
|
204
|
+
expect(result.reason).toBe('tag-excluded');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('excludes stories with zero variants', () => {
|
|
208
|
+
const result = checkStoryExclusion({
|
|
209
|
+
...base,
|
|
210
|
+
variantCount: 0,
|
|
211
|
+
});
|
|
212
|
+
expect(result.excluded).toBe(true);
|
|
213
|
+
expect(result.reason).toBe('empty-variants');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Config precedence
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe('config precedence', () => {
|
|
222
|
+
it('force-includes a component despite SVG icon rule', () => {
|
|
223
|
+
const result = checkStoryExclusion({
|
|
224
|
+
componentName: 'SvgHomeLarge',
|
|
225
|
+
variantCount: 1,
|
|
226
|
+
filePath: 'src/icons/SvgHomeLarge.stories.tsx',
|
|
227
|
+
config: { ...DEFAULTS, include: ['SvgHomeLarge'] },
|
|
228
|
+
});
|
|
229
|
+
expect(result.excluded).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('force-includes a component despite deprecated rule', () => {
|
|
233
|
+
const result = checkStoryExclusion({
|
|
234
|
+
componentName: 'OldButton',
|
|
235
|
+
storybookTitle: 'Deprecated/OldButton',
|
|
236
|
+
variantCount: 1,
|
|
237
|
+
filePath: 'src/components/OldButton.stories.tsx',
|
|
238
|
+
config: { ...DEFAULTS, include: ['OldButton'] },
|
|
239
|
+
});
|
|
240
|
+
expect(result.excluded).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('keeps deprecated when excludeDeprecated is false', () => {
|
|
244
|
+
const result = checkStoryExclusion({
|
|
245
|
+
componentName: 'OldButton',
|
|
246
|
+
storybookTitle: 'Deprecated/OldButton',
|
|
247
|
+
variantCount: 1,
|
|
248
|
+
filePath: 'src/components/OldButton.stories.tsx',
|
|
249
|
+
config: { ...DEFAULTS, excludeDeprecated: false },
|
|
250
|
+
});
|
|
251
|
+
expect(result.excluded).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('keeps test stories when excludeTests is false', () => {
|
|
255
|
+
const result = checkStoryExclusion({
|
|
256
|
+
componentName: 'Button',
|
|
257
|
+
storybookTitle: 'Components/Button/Tests',
|
|
258
|
+
variantCount: 1,
|
|
259
|
+
filePath: 'src/components/Button.stories.tsx',
|
|
260
|
+
config: { ...DEFAULTS, excludeTests: false },
|
|
261
|
+
});
|
|
262
|
+
expect(result.excluded).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('keeps SVG icons when excludeSvgIcons is false', () => {
|
|
266
|
+
const result = checkStoryExclusion({
|
|
267
|
+
componentName: 'SvgArrowRight',
|
|
268
|
+
variantCount: 1,
|
|
269
|
+
filePath: 'src/icons/SvgArrowRight.stories.tsx',
|
|
270
|
+
config: { ...DEFAULTS, excludeSvgIcons: false },
|
|
271
|
+
});
|
|
272
|
+
expect(result.excluded).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('config-excludes a component by name', () => {
|
|
276
|
+
const result = checkStoryExclusion({
|
|
277
|
+
componentName: 'JCB',
|
|
278
|
+
variantCount: 1,
|
|
279
|
+
filePath: 'src/components/JCB.stories.tsx',
|
|
280
|
+
config: { ...DEFAULTS, exclude: ['JCB'] },
|
|
281
|
+
});
|
|
282
|
+
expect(result.excluded).toBe(true);
|
|
283
|
+
expect(result.reason).toBe('config-excluded');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('config-excludes with wildcard pattern', () => {
|
|
287
|
+
const result = checkStoryExclusion({
|
|
288
|
+
componentName: 'SvgHomeLarge',
|
|
289
|
+
variantCount: 1,
|
|
290
|
+
filePath: 'src/icons/SvgHomeLarge.stories.tsx',
|
|
291
|
+
config: { ...DEFAULTS, exclude: ['Svg*'] },
|
|
292
|
+
});
|
|
293
|
+
expect(result.excluded).toBe(true);
|
|
294
|
+
expect(result.reason).toBe('config-excluded');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('include wins over exclude', () => {
|
|
298
|
+
const result = checkStoryExclusion({
|
|
299
|
+
componentName: 'SvgHomeLarge',
|
|
300
|
+
variantCount: 1,
|
|
301
|
+
filePath: 'src/icons/SvgHomeLarge.stories.tsx',
|
|
302
|
+
config: { ...DEFAULTS, include: ['SvgHomeLarge'], exclude: ['Svg*'] },
|
|
303
|
+
});
|
|
304
|
+
expect(result.excluded).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// isForceIncluded / isConfigExcluded helpers
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
describe('isForceIncluded', () => {
|
|
313
|
+
it('returns false with no include patterns', () => {
|
|
314
|
+
expect(isForceIncluded('Button', {})).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('matches exact name', () => {
|
|
318
|
+
expect(isForceIncluded('Button', { include: ['Button'] })).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('matches wildcard prefix', () => {
|
|
322
|
+
expect(isForceIncluded('SvgArrowRight', { include: ['Svg*'] })).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('does not match non-matching pattern', () => {
|
|
326
|
+
expect(isForceIncluded('Button', { include: ['Svg*'] })).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('matches suffix wildcard', () => {
|
|
330
|
+
expect(isForceIncluded('BigButton', { include: ['*Button'] })).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('matches contains wildcard', () => {
|
|
334
|
+
expect(isForceIncluded('MyButtonGroup', { include: ['*Button*'] })).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('isConfigExcluded', () => {
|
|
339
|
+
it('returns false with no exclude patterns', () => {
|
|
340
|
+
expect(isConfigExcluded('Button', {})).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('matches exact name', () => {
|
|
344
|
+
expect(isConfigExcluded('JCB', { exclude: ['JCB'] })).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('matches wildcard', () => {
|
|
348
|
+
expect(isConfigExcluded('SvgHomeLarge', { exclude: ['Svg*'] })).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart filtering for Storybook adapter.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* 1. Per-file heuristics — checkStoryExclusion() checks title, tags, component name, etc.
|
|
6
|
+
* 2. Cross-file sub-component detection — detectSubComponentPaths() uses directory structure.
|
|
7
|
+
*
|
|
8
|
+
* All functions are pure (no I/O, no side effects) for easy testing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { StorybookFilterConfig } from './types.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export type ExclusionReason =
|
|
18
|
+
| 'deprecated' // title contains "Deprecated"
|
|
19
|
+
| 'test-story' // title ends /tests? OR file matches *.test.stories.*
|
|
20
|
+
| 'svg-icon' // component name starts with Svg[A-Z]
|
|
21
|
+
| 'tag-excluded' // meta.tags includes hidden/internal/no-fragment
|
|
22
|
+
| 'empty-variants' // zero renderable story exports
|
|
23
|
+
| 'sub-component' // directory-based: file in another component's folder
|
|
24
|
+
| 'config-excluded'; // user explicit exclude pattern
|
|
25
|
+
|
|
26
|
+
export interface ExclusionResult {
|
|
27
|
+
excluded: boolean;
|
|
28
|
+
reason?: ExclusionReason;
|
|
29
|
+
detail?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Per-file heuristics
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const EXCLUDED_TAGS = new Set(['hidden', 'internal', 'no-fragment']);
|
|
37
|
+
|
|
38
|
+
const SVG_ICON_RE = /^Svg[A-Z]/;
|
|
39
|
+
const TEST_TITLE_RE = /\/tests?$/i;
|
|
40
|
+
const TEST_FILE_RE = /\.test\.stories\./;
|
|
41
|
+
const DEPRECATED_TITLE_RE = /\bDeprecated\b/i;
|
|
42
|
+
|
|
43
|
+
export interface CheckStoryExclusionOpts {
|
|
44
|
+
storybookTitle?: string;
|
|
45
|
+
componentName: string;
|
|
46
|
+
componentDisplayName?: string;
|
|
47
|
+
componentFunctionName?: string;
|
|
48
|
+
tags?: string[];
|
|
49
|
+
variantCount: number;
|
|
50
|
+
filePath: string;
|
|
51
|
+
config: StorybookFilterConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Per-file exclusion check. Returns `{ excluded: true, reason, detail }` when
|
|
56
|
+
* the fragment should be filtered out, or `{ excluded: false }` when it should
|
|
57
|
+
* be kept.
|
|
58
|
+
*
|
|
59
|
+
* Config `include` trumps everything — if a name matches `include`, it is
|
|
60
|
+
* never excluded by heuristics.
|
|
61
|
+
*/
|
|
62
|
+
export function checkStoryExclusion(opts: CheckStoryExclusionOpts): ExclusionResult {
|
|
63
|
+
const { config } = opts;
|
|
64
|
+
|
|
65
|
+
// Force-included names bypass all heuristic filters
|
|
66
|
+
if (isForceIncluded(opts.componentName, config)) {
|
|
67
|
+
return { excluded: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Config explicit exclude
|
|
71
|
+
if (isConfigExcluded(opts.componentName, config)) {
|
|
72
|
+
return {
|
|
73
|
+
excluded: true,
|
|
74
|
+
reason: 'config-excluded',
|
|
75
|
+
detail: `'${opts.componentName}' matches storybook.exclude pattern`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Deprecated
|
|
80
|
+
if (config.excludeDeprecated !== false && opts.storybookTitle && DEPRECATED_TITLE_RE.test(opts.storybookTitle)) {
|
|
81
|
+
return {
|
|
82
|
+
excluded: true,
|
|
83
|
+
reason: 'deprecated',
|
|
84
|
+
detail: `Title "${opts.storybookTitle}" contains "Deprecated"`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Test stories
|
|
89
|
+
if (config.excludeTests !== false) {
|
|
90
|
+
if (opts.storybookTitle && TEST_TITLE_RE.test(opts.storybookTitle)) {
|
|
91
|
+
return {
|
|
92
|
+
excluded: true,
|
|
93
|
+
reason: 'test-story',
|
|
94
|
+
detail: `Title "${opts.storybookTitle}" ends with /test(s)`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (TEST_FILE_RE.test(opts.filePath)) {
|
|
98
|
+
return {
|
|
99
|
+
excluded: true,
|
|
100
|
+
reason: 'test-story',
|
|
101
|
+
detail: `File path matches *.test.stories.*`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// SVG icons
|
|
107
|
+
if (config.excludeSvgIcons !== false) {
|
|
108
|
+
const names = [opts.componentName, opts.componentDisplayName, opts.componentFunctionName].filter(Boolean) as string[];
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
if (SVG_ICON_RE.test(name)) {
|
|
111
|
+
return {
|
|
112
|
+
excluded: true,
|
|
113
|
+
reason: 'svg-icon',
|
|
114
|
+
detail: `Component name "${name}" matches Svg[A-Z] pattern`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Excluded tags
|
|
121
|
+
if (opts.tags?.length) {
|
|
122
|
+
const hit = opts.tags.find(t => EXCLUDED_TAGS.has(t));
|
|
123
|
+
if (hit) {
|
|
124
|
+
return {
|
|
125
|
+
excluded: true,
|
|
126
|
+
reason: 'tag-excluded',
|
|
127
|
+
detail: `Tag "${hit}" is in the exclusion set`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Empty variants
|
|
133
|
+
if (opts.variantCount === 0) {
|
|
134
|
+
return {
|
|
135
|
+
excluded: true,
|
|
136
|
+
reason: 'empty-variants',
|
|
137
|
+
detail: 'Zero renderable story exports',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { excluded: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Cross-file sub-component detection
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Given all story file relative paths, detect which ones are sub-components
|
|
150
|
+
* based on directory structure.
|
|
151
|
+
*
|
|
152
|
+
* Heuristic: within a directory, if one story file's base name matches the
|
|
153
|
+
* directory name, it is the "primary" component. All other story files in
|
|
154
|
+
* the same directory are considered sub-components.
|
|
155
|
+
*
|
|
156
|
+
* Example:
|
|
157
|
+
* src/components/Form/Form.stories.tsx → primary ("Form")
|
|
158
|
+
* src/components/Form/Checkbox.stories.tsx → sub-component of "Form"
|
|
159
|
+
* src/components/Form/RadioGroup.stories.tsx → sub-component of "Form"
|
|
160
|
+
*
|
|
161
|
+
* Returns a Map from relative path → parent component name.
|
|
162
|
+
* Paths NOT in the map are standalone components.
|
|
163
|
+
*/
|
|
164
|
+
export function detectSubComponentPaths(
|
|
165
|
+
storyFiles: Array<{ relativePath: string }>
|
|
166
|
+
): Map<string, string> {
|
|
167
|
+
// Group story files by their parent directory
|
|
168
|
+
const byDir = new Map<string, Array<{ relativePath: string; baseName: string }>>();
|
|
169
|
+
|
|
170
|
+
for (const file of storyFiles) {
|
|
171
|
+
const parts = file.relativePath.split('/');
|
|
172
|
+
if (parts.length < 2) continue; // skip root-level files
|
|
173
|
+
|
|
174
|
+
const fileName = parts[parts.length - 1];
|
|
175
|
+
// Extract base name: "Form.stories.tsx" → "Form"
|
|
176
|
+
const baseMatch = fileName.match(/^([^.]+)\.stories\./);
|
|
177
|
+
if (!baseMatch) continue;
|
|
178
|
+
|
|
179
|
+
const dir = parts.slice(0, -1).join('/');
|
|
180
|
+
const baseName = baseMatch[1];
|
|
181
|
+
|
|
182
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
183
|
+
byDir.get(dir)!.push({ relativePath: file.relativePath, baseName });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const subComponentMap = new Map<string, string>();
|
|
187
|
+
|
|
188
|
+
for (const [dir, files] of byDir) {
|
|
189
|
+
if (files.length <= 1) continue; // single file in dir → always standalone
|
|
190
|
+
|
|
191
|
+
// Directory name is the last segment: "src/components/Form" → "Form"
|
|
192
|
+
const dirName = dir.split('/').pop()!;
|
|
193
|
+
|
|
194
|
+
// Find the primary: story whose base name matches the directory name
|
|
195
|
+
const primary = files.find(f => f.baseName === dirName);
|
|
196
|
+
if (!primary) continue; // no clear primary → keep all
|
|
197
|
+
|
|
198
|
+
// All others in this dir are sub-components
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
if (file.relativePath === primary.relativePath) continue;
|
|
201
|
+
subComponentMap.set(file.relativePath, primary.baseName);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return subComponentMap;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Config helpers
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check if a component name matches the `storybook.include` patterns.
|
|
214
|
+
* Include is a force-include that bypasses all heuristic filters.
|
|
215
|
+
*/
|
|
216
|
+
export function isForceIncluded(name: string, config: StorybookFilterConfig): boolean {
|
|
217
|
+
if (!config.include?.length) return false;
|
|
218
|
+
return config.include.some(pattern => matchesPattern(name, pattern));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if a component name matches the `storybook.exclude` patterns.
|
|
223
|
+
*/
|
|
224
|
+
export function isConfigExcluded(name: string, config: StorybookFilterConfig): boolean {
|
|
225
|
+
if (!config.exclude?.length) return false;
|
|
226
|
+
return config.exclude.some(pattern => matchesPattern(name, pattern));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Simple pattern matching: exact match or glob-style prefix/suffix wildcards.
|
|
231
|
+
* "Button" → exact match
|
|
232
|
+
* "Svg*" → prefix match
|
|
233
|
+
* "*Icon" → suffix match
|
|
234
|
+
* "*Badge*" → contains match
|
|
235
|
+
*/
|
|
236
|
+
function matchesPattern(name: string, pattern: string): boolean {
|
|
237
|
+
if (!pattern.includes('*')) {
|
|
238
|
+
return name === pattern;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parts = pattern.split('*');
|
|
242
|
+
if (parts.length === 2) {
|
|
243
|
+
const [prefix, suffix] = parts;
|
|
244
|
+
if (prefix && suffix) return name.startsWith(prefix) && name.endsWith(suffix);
|
|
245
|
+
if (prefix) return name.startsWith(prefix);
|
|
246
|
+
if (suffix) return name.endsWith(suffix);
|
|
247
|
+
return true; // pattern is just "*"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Multi-wildcard: convert to regex
|
|
251
|
+
const escaped = parts.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
|
|
252
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
253
|
+
}
|
|
@@ -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);
|