@tpitre/story-ui 3.6.2 → 3.7.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.
Files changed (44) hide show
  1. package/README.md +36 -32
  2. package/dist/cli/index.js +0 -5
  3. package/dist/cli/setup.js +1 -1
  4. package/dist/mcp-server/routes/generateStory.d.ts.map +1 -1
  5. package/dist/mcp-server/routes/generateStory.js +142 -87
  6. package/dist/mcp-server/routes/generateStoryStream.d.ts.map +1 -1
  7. package/dist/mcp-server/routes/generateStoryStream.js +149 -31
  8. package/dist/story-generator/dynamicPackageDiscovery.d.ts +35 -2
  9. package/dist/story-generator/dynamicPackageDiscovery.d.ts.map +1 -1
  10. package/dist/story-generator/dynamicPackageDiscovery.js +332 -6
  11. package/dist/story-generator/enhancedComponentDiscovery.d.ts.map +1 -1
  12. package/dist/story-generator/enhancedComponentDiscovery.js +149 -2
  13. package/dist/story-generator/framework-adapters/base-adapter.d.ts +1 -0
  14. package/dist/story-generator/framework-adapters/base-adapter.d.ts.map +1 -1
  15. package/dist/story-generator/framework-adapters/base-adapter.js +12 -2
  16. package/dist/story-generator/framework-adapters/react-adapter.d.ts.map +1 -1
  17. package/dist/story-generator/framework-adapters/react-adapter.js +2 -0
  18. package/dist/story-generator/framework-adapters/svelte-adapter.d.ts.map +1 -1
  19. package/dist/story-generator/framework-adapters/svelte-adapter.js +53 -7
  20. package/dist/story-generator/framework-adapters/vue-adapter.d.ts.map +1 -1
  21. package/dist/story-generator/framework-adapters/vue-adapter.js +21 -1
  22. package/dist/story-generator/framework-adapters/web-components-adapter.d.ts.map +1 -1
  23. package/dist/story-generator/framework-adapters/web-components-adapter.js +4 -0
  24. package/dist/story-generator/llm-providers/openai-provider.js +2 -2
  25. package/dist/story-generator/promptGenerator.d.ts.map +1 -1
  26. package/dist/story-generator/promptGenerator.js +179 -26
  27. package/dist/story-generator/selfHealingLoop.d.ts +112 -0
  28. package/dist/story-generator/selfHealingLoop.d.ts.map +1 -0
  29. package/dist/story-generator/selfHealingLoop.js +202 -0
  30. package/dist/story-generator/validateStory.d.ts.map +1 -1
  31. package/dist/story-generator/validateStory.js +81 -12
  32. package/dist/story-ui.config.d.ts +2 -0
  33. package/dist/story-ui.config.d.ts.map +1 -1
  34. package/dist/templates/StoryUI/StoryUIPanel.d.ts +0 -5
  35. package/dist/templates/StoryUI/StoryUIPanel.d.ts.map +1 -1
  36. package/dist/templates/StoryUI/StoryUIPanel.js +411 -223
  37. package/package.json +4 -4
  38. package/templates/StoryUI/StoryUIPanel.mdx +84 -0
  39. package/templates/StoryUI/StoryUIPanel.tsx +493 -265
  40. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts +0 -18
  41. package/dist/templates/StoryUI/StoryUIPanel.stories.d.ts.map +0 -1
  42. package/dist/templates/StoryUI/StoryUIPanel.stories.js +0 -37
  43. package/templates/StoryUI/StoryUIPanel.stories.tsx +0 -44
  44. package/templates/StoryUI/manager.tsx +0 -859
@@ -1,859 +0,0 @@
1
- /**
2
- * Story UI Storybook Manager Addon
3
- *
4
- * This addon adds a "Source Code" panel to Storybook that displays
5
- * the source code of the currently viewed story with syntax highlighting.
6
- *
7
- * Works in both local development and production deployments.
8
- */
9
-
10
- import { addons, types, useStorybookApi, useStorybookState } from 'storybook/manager-api';
11
- import React, { useEffect, useState, useCallback, useMemo } from 'react';
12
-
13
- // Addon identifier
14
- const ADDON_ID = 'story-ui';
15
- const PANEL_ID = `${ADDON_ID}/source-code`;
16
-
17
- // Event channel for receiving generated code from StoryUIPanel
18
- const EVENTS = {
19
- CODE_GENERATED: `${ADDON_ID}/code-generated`,
20
- STORY_SELECTED: `${ADDON_ID}/story-selected`,
21
- };
22
-
23
- /**
24
- * Get the API base URL for story operations.
25
- * Works in both local development and production (Railway).
26
- */
27
- const getApiBaseUrl = (): string => {
28
- if (typeof window === 'undefined') return 'http://localhost:4001';
29
-
30
- // Check for Railway production domain - use same-origin requests
31
- const hostname = window.location.hostname;
32
- if (hostname.includes('.railway.app')) {
33
- return '';
34
- }
35
-
36
- // Check for window overrides (local development)
37
- const windowOverride = (window as any).__STORY_UI_PORT__;
38
- if (windowOverride) return `http://localhost:${windowOverride}`;
39
-
40
- const mcpOverride = (window as any).STORY_UI_MCP_PORT;
41
- if (mcpOverride) return `http://localhost:${mcpOverride}`;
42
-
43
- return 'http://localhost:4001';
44
- };
45
-
46
- // Extend Window to include generated stories cache
47
- declare global {
48
- interface Window {
49
- __STORY_UI_GENERATED_CODE__?: Record<string, string>;
50
- }
51
- }
52
-
53
- /**
54
- * Extract clean component usage JSX from a full Storybook story file.
55
- *
56
- * Transforms:
57
- * import { Button } from '@mantine/core';
58
- * export default { title: 'Generated/Button' };
59
- * export const Default: Story = { render: () => <Button>Click</Button> };
60
- *
61
- * Into:
62
- * <Button>Click</Button>
63
- */
64
- const extractUsageCode = (fullStoryCode: string, variantName?: string): string => {
65
- // Helper function to convert object-style props to JSX attribute syntax
66
- // e.g., "color: 'blue', variant: 'filled'" -> 'color="blue" variant="filled"'
67
- const convertToJsxAttributes = (propsStr: string): string => {
68
- if (!propsStr.trim()) return '';
69
-
70
- const attributes: string[] = [];
71
- // Match key: value pairs, handling strings, booleans, numbers, and expressions
72
- // Pattern: key: 'value' or key: "value" or key: true/false or key: 123 or key: expression
73
- const propRegex = /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|(\btrue\b|\bfalse\b)|(\d+(?:\.\d+)?)|(\{[^}]+\})|([^,}\s]+))/g;
74
-
75
- let match;
76
- while ((match = propRegex.exec(propsStr)) !== null) {
77
- const key = match[1];
78
- const stringValueSingle = match[2]; // 'value'
79
- const stringValueDouble = match[3]; // "value"
80
- const boolValue = match[4]; // true/false
81
- const numValue = match[5]; // 123 or 1.5
82
- const objValue = match[6]; // {expression}
83
- const otherValue = match[7]; // other expressions
84
-
85
- if (stringValueSingle !== undefined) {
86
- attributes.push(`${key}="${stringValueSingle}"`);
87
- } else if (stringValueDouble !== undefined) {
88
- attributes.push(`${key}="${stringValueDouble}"`);
89
- } else if (boolValue !== undefined) {
90
- if (boolValue === 'true') {
91
- attributes.push(key); // Just the prop name for true (e.g., fullWidth)
92
- }
93
- // Skip false values - they're the default and don't need to be shown
94
- } else if (numValue !== undefined) {
95
- attributes.push(`${key}={${numValue}}`);
96
- } else if (objValue !== undefined) {
97
- attributes.push(`${key}=${objValue}`);
98
- } else if (otherValue !== undefined) {
99
- attributes.push(`${key}={${otherValue}}`);
100
- }
101
- }
102
-
103
- return attributes.join(' ');
104
- };
105
-
106
- // Helper function to generate JSX from args
107
- const generateJsxFromArgs = (argsStr: string, componentName: string): string | null => {
108
- try {
109
- // Extract children if present
110
- const childrenMatch = argsStr.match(/children:\s*['"`]([^'"`]+)['"`]/);
111
- const children = childrenMatch ? childrenMatch[1] : '';
112
-
113
- // Extract other props (remove children first)
114
- const propsStr = argsStr
115
- .replace(/children:\s*['"`][^'"`]*['"`],?/, '') // Remove children
116
- .replace(/^\{|\}$/g, '') // Remove braces
117
- .trim();
118
-
119
- // Convert to JSX attribute syntax
120
- const jsxAttributes = convertToJsxAttributes(propsStr);
121
-
122
- if (children) {
123
- if (jsxAttributes) {
124
- return `<${componentName} ${jsxAttributes}>${children}</${componentName}>`;
125
- }
126
- return `<${componentName}>${children}</${componentName}>`;
127
- } else if (jsxAttributes) {
128
- return `<${componentName} ${jsxAttributes} />`;
129
- }
130
- return `<${componentName} />`;
131
- } catch {
132
- return null;
133
- }
134
- };
135
-
136
- // Get the component name from meta
137
- const componentMatch = fullStoryCode.match(/component:\s*([A-Z][A-Za-z0-9]*)/);
138
- const componentName = componentMatch ? componentMatch[1] : null;
139
-
140
- // If we have a variant name, try to find that specific variant's args or render
141
- if (variantName) {
142
- // Normalize variant name for matching:
143
- // - "primary" -> "Primary"
144
- // - "full-width" -> "FullWidth" (kebab-case to PascalCase)
145
- const normalizedVariant = variantName
146
- .split('-')
147
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
148
- .join('');
149
-
150
- // Pattern A: export const Primary: Story = { args: {...} }
151
- // Match the specific variant's export block
152
- const variantExportRegex = new RegExp(
153
- `export\\s+const\\s+${normalizedVariant}\\s*(?::\\s*Story)?\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*;`,
154
- 'i'
155
- );
156
- const variantExportMatch = fullStoryCode.match(variantExportRegex);
157
-
158
- if (variantExportMatch) {
159
- const variantBlock = variantExportMatch[1];
160
-
161
- // Try to extract render function from this variant
162
- const renderWithParensMatch = variantBlock.match(/render:\s*\([^)]*\)\s*=>\s*\(\s*([\s\S]*?)\s*\)\s*[,}]/);
163
- if (renderWithParensMatch) {
164
- return renderWithParensMatch[1].trim().replace(/,\s*$/, '');
165
- }
166
-
167
- const renderNoParensMatch = variantBlock.match(/render:\s*\([^)]*\)\s*=>\s*(<[A-Z][^,}]*(?:\/>|<\/[A-Za-z.]+>))/s);
168
- if (renderNoParensMatch) {
169
- return renderNoParensMatch[1].trim();
170
- }
171
-
172
- // Try to extract args from this variant
173
- const argsMatch = variantBlock.match(/args:\s*(\{[\s\S]*?\})\s*[,}]/);
174
- if (argsMatch && componentName) {
175
- const result = generateJsxFromArgs(argsMatch[1], componentName);
176
- if (result) return result;
177
- }
178
- }
179
-
180
- // Pattern B: Arrow function variant: export const Primary = () => <Component...>
181
- const arrowVariantRegex = new RegExp(
182
- `export\\s+const\\s+${normalizedVariant}\\s*=\\s*\\(\\)\\s*=>\\s*\\(\\s*([\\s\\S]*?)\\s*\\)\\s*;`,
183
- 'i'
184
- );
185
- const arrowVariantMatch = fullStoryCode.match(arrowVariantRegex);
186
- if (arrowVariantMatch) {
187
- return arrowVariantMatch[1].trim().replace(/,\s*$/, '');
188
- }
189
-
190
- // Pattern C: Arrow function without parens: export const Primary = () => <Component...>;
191
- const arrowNoParensRegex = new RegExp(
192
- `export\\s+const\\s+${normalizedVariant}\\s*=\\s*\\(\\)\\s*=>\\s*(<[A-Z][^;]*(?:\\/>|<\\/[A-Za-z.]+>))\\s*;`,
193
- 'is'
194
- );
195
- const arrowNoParensMatch = fullStoryCode.match(arrowNoParensRegex);
196
- if (arrowNoParensMatch) {
197
- return arrowNoParensMatch[1].trim();
198
- }
199
- }
200
-
201
- // Fallback: Try generic patterns (for Default or when variant not specified)
202
-
203
- // Try to extract JSX from render function: render: () => (<JSX>) or render: () => <JSX>
204
- // Pattern 1: render: () => (\n <Component...>\n)
205
- const renderWithParensMatch = fullStoryCode.match(/render:\s*\([^)]*\)\s*=>\s*\(\s*([\s\S]*?)\s*\)\s*[,}]/);
206
- if (renderWithParensMatch) {
207
- const jsx = renderWithParensMatch[1].trim();
208
- // Clean up any trailing commas or extra whitespace
209
- return jsx.replace(/,\s*$/, '').trim();
210
- }
211
-
212
- // Pattern 2: render: () => <Component...> (no parentheses, single line)
213
- const renderNoParensMatch = fullStoryCode.match(/render:\s*\([^)]*\)\s*=>\s*(<[A-Z][^,}]*(?:\/>|<\/[A-Za-z.]+>))/s);
214
- if (renderNoParensMatch) {
215
- return renderNoParensMatch[1].trim();
216
- }
217
-
218
- // Pattern 3: Arrow function story: () => (<JSX>)
219
- const arrowWithParensMatch = fullStoryCode.match(/export\s+const\s+\w+\s*=\s*\(\)\s*=>\s*\(\s*([\s\S]*?)\s*\)\s*;/);
220
- if (arrowWithParensMatch) {
221
- const jsx = arrowWithParensMatch[1].trim();
222
- return jsx.replace(/,\s*$/, '').trim();
223
- }
224
-
225
- // Pattern 4: Arrow function story: () => <Component...>
226
- const arrowNoParensMatch = fullStoryCode.match(/export\s+const\s+\w+\s*=\s*\(\)\s*=>\s*(<[A-Z][^;]*(?:\/>|<\/[A-Za-z.]+>))\s*;/s);
227
- if (arrowNoParensMatch) {
228
- return arrowNoParensMatch[1].trim();
229
- }
230
-
231
- // Pattern 5: Look for args-based stories with component prop spreading
232
- // e.g., args: { children: 'Click me', color: 'blue' }
233
- const argsMatch = fullStoryCode.match(/args:\s*(\{[\s\S]*?\})\s*[,}]/);
234
- if (argsMatch && componentName) {
235
- const result = generateJsxFromArgs(argsMatch[1], componentName);
236
- if (result) return result;
237
- }
238
-
239
- // Pattern 6: Look for any JSX block starting with < and ending with /> or </Component>
240
- // This is a fallback for any JSX we can find
241
- const jsxBlockMatch = fullStoryCode.match(/(<[A-Z][a-zA-Z0-9.]*[\s\S]*?(?:\/>|<\/[A-Za-z.]+>))/);
242
- if (jsxBlockMatch) {
243
- // Don't return if it looks like an import or type definition
244
- const match = jsxBlockMatch[1];
245
- if (!match.includes('import') && !match.includes('Meta<') && !match.includes('StoryObj<')) {
246
- return match.trim();
247
- }
248
- }
249
-
250
- // If no patterns matched, return the original code
251
- // (better than showing nothing)
252
- return fullStoryCode;
253
- };
254
-
255
- /**
256
- * Simple Prism-like syntax highlighting for JSX/TSX
257
- * Uses inline styles for portability (no external CSS needed)
258
- */
259
- const tokenize = (code: string): Array<{ type: string; value: string }> => {
260
- const tokens: Array<{ type: string; value: string }> = [];
261
- let remaining = code;
262
-
263
- const patterns: Array<{ type: string; regex: RegExp }> = [
264
- // Comments
265
- { type: 'comment', regex: /^(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/ },
266
- // Strings (double, single, template)
267
- { type: 'string', regex: /^("[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*'|`[^`\\]*(?:\\.[^`\\]*)*`)/ },
268
- // JSX tags
269
- { type: 'tag', regex: /^(<\/?[A-Z][a-zA-Z0-9.]*|<\/?[a-z][a-z0-9-]*)/ },
270
- // Closing tag bracket
271
- { type: 'punctuation', regex: /^(\/>|>)/ },
272
- // Keywords
273
- { type: 'keyword', regex: /^(const|let|var|function|return|export|default|import|from|if|else|for|while|class|extends|new|this|typeof|instanceof|async|await|try|catch|throw|finally)\b/ },
274
- // Booleans and null
275
- { type: 'boolean', regex: /^(true|false|null|undefined)\b/ },
276
- // Numbers
277
- { type: 'number', regex: /^-?\d+\.?\d*(e[+-]?\d+)?/ },
278
- // Props/attributes (word followed by =)
279
- { type: 'attr-name', regex: /^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?==)/ },
280
- // Function names
281
- { type: 'function', regex: /^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/ },
282
- // Identifiers
283
- { type: 'plain', regex: /^[a-zA-Z_$][a-zA-Z0-9_$]*/ },
284
- // Operators
285
- { type: 'operator', regex: /^(=>|===|!==|==|!=|<=|>=|&&|\|\||[+\-*/%=<>!&|^~?:])/ },
286
- // Punctuation
287
- { type: 'punctuation', regex: /^[{}[\]();,.]/ },
288
- // Whitespace
289
- { type: 'whitespace', regex: /^\s+/ },
290
- ];
291
-
292
- while (remaining.length > 0) {
293
- let matched = false;
294
-
295
- for (const { type, regex } of patterns) {
296
- const match = remaining.match(regex);
297
- if (match) {
298
- tokens.push({ type, value: match[0] });
299
- remaining = remaining.slice(match[0].length);
300
- matched = true;
301
- break;
302
- }
303
- }
304
-
305
- if (!matched) {
306
- // Unknown character, add as plain
307
- tokens.push({ type: 'plain', value: remaining[0] });
308
- remaining = remaining.slice(1);
309
- }
310
- }
311
-
312
- return tokens;
313
- };
314
-
315
- /**
316
- * Color scheme for syntax highlighting (VS Code-like dark theme)
317
- */
318
- const tokenColors: Record<string, React.CSSProperties> = {
319
- comment: { color: '#6A9955', fontStyle: 'italic' },
320
- string: { color: '#CE9178' },
321
- tag: { color: '#569CD6' },
322
- keyword: { color: '#C586C0' },
323
- boolean: { color: '#569CD6' },
324
- number: { color: '#B5CEA8' },
325
- 'attr-name': { color: '#9CDCFE' },
326
- function: { color: '#DCDCAA' },
327
- operator: { color: '#D4D4D4' },
328
- punctuation: { color: '#D4D4D4' },
329
- plain: { color: '#D4D4D4' },
330
- whitespace: {},
331
- };
332
-
333
- /**
334
- * Syntax Highlighter Component
335
- */
336
- const SyntaxHighlighter: React.FC<{ code: string }> = ({ code }) => {
337
- const tokens = useMemo(() => tokenize(code), [code]);
338
-
339
- return (
340
- <pre
341
- style={{
342
- margin: 0,
343
- padding: '16px',
344
- background: '#1E1E1E',
345
- borderRadius: '4px',
346
- overflow: 'auto',
347
- fontSize: '13px',
348
- lineHeight: '1.5',
349
- fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
350
- }}
351
- >
352
- <code>
353
- {tokens.map((token, index) => (
354
- <span key={index} style={tokenColors[token.type] || {}}>
355
- {token.value}
356
- </span>
357
- ))}
358
- </code>
359
- </pre>
360
- );
361
- };
362
-
363
- /**
364
- * Inline styles for the panel
365
- */
366
- const styles = {
367
- container: {
368
- height: '100%',
369
- display: 'flex',
370
- flexDirection: 'column' as const,
371
- background: '#1E1E1E',
372
- color: '#D4D4D4',
373
- },
374
- header: {
375
- display: 'flex',
376
- justifyContent: 'space-between',
377
- alignItems: 'center',
378
- padding: '8px 12px',
379
- borderBottom: '1px solid #3C3C3C',
380
- background: '#252526',
381
- },
382
- title: {
383
- fontSize: '12px',
384
- fontWeight: 600,
385
- color: '#CCCCCC',
386
- textTransform: 'uppercase' as const,
387
- letterSpacing: '0.5px',
388
- },
389
- copyButton: {
390
- background: '#0E639C',
391
- color: 'white',
392
- border: 'none',
393
- borderRadius: '4px',
394
- padding: '4px 10px',
395
- fontSize: '11px',
396
- cursor: 'pointer',
397
- fontWeight: 500,
398
- },
399
- codeContainer: {
400
- flex: 1,
401
- overflow: 'auto',
402
- padding: '0',
403
- },
404
- emptyState: {
405
- display: 'flex',
406
- flexDirection: 'column' as const,
407
- alignItems: 'center',
408
- justifyContent: 'center',
409
- height: '100%',
410
- color: '#888',
411
- fontSize: '13px',
412
- textAlign: 'center' as const,
413
- padding: '20px',
414
- },
415
- storyInfo: {
416
- fontSize: '11px',
417
- color: '#888',
418
- marginTop: '4px',
419
- },
420
- deleteButton: {
421
- background: 'transparent',
422
- color: '#C75050',
423
- border: '1px solid #C75050',
424
- borderRadius: '4px',
425
- padding: '4px 10px',
426
- fontSize: '11px',
427
- cursor: 'pointer',
428
- fontWeight: 500,
429
- },
430
- deleteButtonHover: {
431
- background: '#C75050',
432
- color: 'white',
433
- },
434
- deleteButtonDeleting: {
435
- background: '#555',
436
- color: '#888',
437
- border: '1px solid #555',
438
- cursor: 'not-allowed',
439
- },
440
- };
441
-
442
- /**
443
- * Source Code Panel Component
444
- */
445
- const SourceCodePanel: React.FC<{ active?: boolean }> = ({ active }) => {
446
- const api = useStorybookApi();
447
- const state = useStorybookState();
448
- const [sourceCode, setSourceCode] = useState<string>('');
449
- const [storyTitle, setStoryTitle] = useState<string>('');
450
- const [copied, setCopied] = useState(false);
451
- const [showFullCode, setShowFullCode] = useState(false);
452
- const [isDeleting, setIsDeleting] = useState(false);
453
- const [deleteHover, setDeleteHover] = useState(false);
454
-
455
- // Get the current story ID
456
- const currentStoryId = state?.storyId;
457
-
458
- // Extract variant name from story ID (e.g., "generated-button--primary" -> "primary", "generated-button--full-width" -> "full-width")
459
- const currentVariant = useMemo(() => {
460
- if (!currentStoryId) return undefined;
461
- // Match everything after the last -- (variant can contain hyphens like "full-width")
462
- const variantMatch = currentStoryId.match(/--([a-z0-9-]+)$/i);
463
- return variantMatch ? variantMatch[1] : undefined;
464
- }, [currentStoryId]);
465
-
466
- // Memoize the usage code extraction with variant awareness
467
- const usageCode = useMemo(() => {
468
- if (!sourceCode) return '';
469
- return extractUsageCode(sourceCode, currentVariant);
470
- }, [sourceCode, currentVariant]);
471
-
472
- const displayCode = useMemo(() => {
473
- if (!sourceCode) return '';
474
- return showFullCode ? sourceCode : usageCode;
475
- }, [sourceCode, showFullCode, usageCode]);
476
-
477
- // Check if there's different usage code (for showing toggle button)
478
- // This should remain true even when showing full code
479
- const hasUsageCode = useMemo(() => {
480
- return sourceCode && usageCode && usageCode !== sourceCode;
481
- }, [sourceCode, usageCode]);
482
-
483
- // Check if this is a generated story (for showing delete button)
484
- const isGeneratedStory = useMemo(() => {
485
- return currentStoryId?.includes('generated') || false;
486
- }, [currentStoryId]);
487
-
488
- // Extract story file ID from the Storybook story ID
489
- // e.g., "generated-simple-test-button--primary" -> "SimpleTestButton"
490
- const getStoryFileId = useCallback((storyId: string): string => {
491
- // Remove "generated-" prefix and variant suffix
492
- const match = storyId.match(/^generated[-\/]?(.+?)(?:--.*)?$/i);
493
- if (match) {
494
- // Convert kebab-case to PascalCase: "simple-test-button" -> "SimpleTestButton"
495
- const words = match[1].split('-');
496
- return words.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('');
497
- }
498
- return storyId;
499
- }, []);
500
-
501
- // Handle delete story
502
- const handleDelete = useCallback(async () => {
503
- if (!currentStoryId || !isGeneratedStory || isDeleting) return;
504
-
505
- const confirmed = window.confirm('Delete this story file and ALL its variants? This cannot be undone.');
506
- if (!confirmed) return;
507
-
508
- setIsDeleting(true);
509
-
510
- try {
511
- const apiBase = getApiBaseUrl();
512
- const storyFileId = getStoryFileId(currentStoryId);
513
-
514
- console.log('[Source Code Panel] Deleting story:', { currentStoryId, storyFileId, apiBase });
515
-
516
- // Try the RESTful DELETE endpoint first
517
- const response = await fetch(`${apiBase}/story-ui/stories/${storyFileId}`, {
518
- method: 'DELETE',
519
- headers: { 'Content-Type': 'application/json' },
520
- });
521
-
522
- if (response.ok) {
523
- console.log('[Source Code Panel] Story deleted successfully');
524
-
525
- // Clear local cache
526
- const topWindow = window.top || window;
527
- if (topWindow.__STORY_UI_GENERATED_CODE__) {
528
- delete topWindow.__STORY_UI_GENERATED_CODE__[currentStoryId];
529
- }
530
- if (window.__STORY_UI_GENERATED_CODE__) {
531
- delete window.__STORY_UI_GENERATED_CODE__[currentStoryId];
532
- }
533
-
534
- // Clear localStorage cache
535
- try {
536
- const stored = JSON.parse(localStorage.getItem('storyui_generated_code') || '{}');
537
- delete stored[storyFileId];
538
- delete stored[currentStoryId];
539
- localStorage.setItem('storyui_generated_code', JSON.stringify(stored));
540
- } catch (e) {
541
- console.warn('[Source Code Panel] Failed to clear localStorage:', e);
542
- }
543
-
544
- // Clear the source code display
545
- setSourceCode('');
546
-
547
- // Navigate to a different story (Story UI Generator default)
548
- api.selectStory('story-ui-story-generator--default');
549
-
550
- // Trigger Storybook refresh to update sidebar
551
- window.location.reload();
552
- } else {
553
- const errorData = await response.json().catch(() => ({}));
554
- console.error('[Source Code Panel] Delete failed:', errorData);
555
- alert(`Failed to delete story: ${errorData.error || 'Unknown error'}`);
556
- }
557
- } catch (error) {
558
- console.error('[Source Code Panel] Delete error:', error);
559
- alert('Failed to delete story. Check console for details.');
560
- } finally {
561
- setIsDeleting(false);
562
- }
563
- }, [currentStoryId, isGeneratedStory, isDeleting, api, getStoryFileId]);
564
-
565
- // Try to get source code from the story
566
- useEffect(() => {
567
- if (!currentStoryId || !active) return;
568
-
569
- // Get story data from the API
570
- const story = api.getData(currentStoryId);
571
-
572
- // Check if this is a generated story based on ID (works even if story doesn't exist in Storybook yet)
573
- const isGeneratedStory = currentStoryId.includes('generated');
574
-
575
- if (story) {
576
- setStoryTitle(story.title || '');
577
-
578
- // Try to get source from story parameters
579
- const storySource = (story as any)?.parameters?.docs?.source?.code ||
580
- (story as any)?.parameters?.storySource?.source ||
581
- (story as any)?.parameters?.source?.code;
582
-
583
- if (storySource) {
584
- setSourceCode(storySource);
585
- return;
586
- }
587
- } else {
588
- // Story doesn't exist in Storybook yet, set title from story ID
589
- setStoryTitle(currentStoryId);
590
- }
591
-
592
- // For generated stories (whether or not they exist in Storybook), try to get from cache/localStorage
593
- if (isGeneratedStory) {
594
- // Try to get from window cache first
595
- const topWindow = window.top || window;
596
- let cachedCode = topWindow.__STORY_UI_GENERATED_CODE__?.[currentStoryId] ||
597
- window.__STORY_UI_GENERATED_CODE__?.[currentStoryId];
598
-
599
- console.log('[Source Code Panel DEBUG] Looking for code:', {
600
- currentStoryId,
601
- foundInWindowCache: !!cachedCode,
602
- windowCacheKeys: Object.keys(topWindow.__STORY_UI_GENERATED_CODE__ || {}),
603
- });
604
-
605
- // If not in memory cache, check localStorage (survives page navigation)
606
- if (!cachedCode) {
607
- try {
608
- const stored = JSON.parse(localStorage.getItem('storyui_generated_code') || '{}');
609
-
610
- console.log('[Source Code Panel DEBUG] localStorage lookup:', {
611
- localStorageKeys: Object.keys(stored),
612
- localStorageKeyCount: Object.keys(stored).length,
613
- });
614
-
615
- // Try multiple key formats since Storybook IDs differ from our storage keys
616
- // Storybook ID format: "generated-componentname--variant" or "generated/componentname--variant"
617
- // Our storage keys: "ComponentName", "ComponentName.stories.tsx", "story-hash123", etc.
618
- const keysToTry: string[] = [currentStoryId];
619
-
620
- // Extract component name and base story ID from Storybook ID
621
- // e.g., "generated-simple-test-button--primary" -> baseId: "generated-simple-test-button--default", component: "simpletestbutton"
622
- const match = currentStoryId.match(/^(generated[-\/]?.+?)(?:--(.*))?$/i);
623
- if (match) {
624
- const baseId = match[1];
625
- const variant = match[2];
626
-
627
- // Try base story ID with --default variant (this is what we store)
628
- if (variant && variant !== 'default') {
629
- keysToTry.push(`${baseId}--default`);
630
- }
631
- // Also try just the base ID without any variant
632
- keysToTry.push(baseId);
633
-
634
- // Extract component name (e.g., "generated-simple-test-button" -> "simpletestbutton")
635
- const componentMatch = baseId.match(/^generated[-\/]?(.+)$/i);
636
- if (componentMatch) {
637
- const componentNameLower = componentMatch[1].replace(/-/g, '');
638
- keysToTry.push(componentNameLower);
639
- // Try PascalCase version (e.g., "simpletestbutton" -> "Simpletestbutton")
640
- const pascalCase = componentNameLower.charAt(0).toUpperCase() + componentNameLower.slice(1);
641
- keysToTry.push(pascalCase);
642
- keysToTry.push(`${pascalCase}.stories.tsx`);
643
-
644
- // Try with spaces converted to title case (e.g., "Simple Test Button" -> "SimpleTestButton")
645
- const words = componentMatch[1].split('-');
646
- if (words.length > 1) {
647
- const titleCase = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
648
- keysToTry.push(titleCase);
649
- // Also try with space-separated title (what we store)
650
- const spacedTitle = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
651
- keysToTry.push(spacedTitle);
652
- }
653
- }
654
- }
655
-
656
- // Also try the story title if available
657
- if (storyTitle) {
658
- keysToTry.push(storyTitle);
659
- keysToTry.push(storyTitle.replace(/\s+/g, ''));
660
- keysToTry.push(`${storyTitle.replace(/\s+/g, '')}.stories.tsx`);
661
- }
662
-
663
- console.log('[Source Code Panel DEBUG] trying keys:', keysToTry);
664
-
665
- // Try each key format
666
- for (const key of keysToTry) {
667
- if (stored[key]) {
668
- cachedCode = stored[key];
669
- console.log('[Source Code Panel DEBUG] found code with key:', key, 'codeLength:', cachedCode?.length);
670
- break;
671
- }
672
- }
673
-
674
- // Restore to memory cache if found
675
- if (cachedCode) {
676
- if (!topWindow.__STORY_UI_GENERATED_CODE__) {
677
- topWindow.__STORY_UI_GENERATED_CODE__ = {};
678
- }
679
- topWindow.__STORY_UI_GENERATED_CODE__[currentStoryId] = cachedCode;
680
- }
681
- } catch (e) {
682
- console.warn('[Story UI] Failed to read from localStorage:', e);
683
- }
684
- }
685
-
686
- console.log('[Source Code Panel DEBUG] final result:', {
687
- foundCode: !!cachedCode,
688
- codeLength: cachedCode?.length,
689
- });
690
-
691
- if (cachedCode) {
692
- setSourceCode(cachedCode);
693
- } else {
694
- setSourceCode('');
695
- }
696
- } else {
697
- setSourceCode('');
698
- }
699
- }, [currentStoryId, active, api]);
700
-
701
- // Listen for code generated events from StoryUIPanel
702
- useEffect(() => {
703
- const channel = addons.getChannel();
704
- const topWindow = window.top || window;
705
-
706
- const handleCodeGenerated = (data: { storyId: string; code: string }) => {
707
- // Store in window cache (both local and top window)
708
- if (!window.__STORY_UI_GENERATED_CODE__) {
709
- window.__STORY_UI_GENERATED_CODE__ = {};
710
- }
711
- window.__STORY_UI_GENERATED_CODE__[data.storyId] = data.code;
712
-
713
- if (topWindow !== window) {
714
- if (!topWindow.__STORY_UI_GENERATED_CODE__) {
715
- topWindow.__STORY_UI_GENERATED_CODE__ = {};
716
- }
717
- topWindow.__STORY_UI_GENERATED_CODE__[data.storyId] = data.code;
718
- }
719
-
720
- // If this is the current story, update the display
721
- if (data.storyId === currentStoryId) {
722
- setSourceCode(data.code);
723
- }
724
- };
725
-
726
- channel.on(EVENTS.CODE_GENERATED, handleCodeGenerated);
727
-
728
- return () => {
729
- channel.off(EVENTS.CODE_GENERATED, handleCodeGenerated);
730
- };
731
- }, [currentStoryId]);
732
-
733
- const handleCopy = useCallback(() => {
734
- if (displayCode) {
735
- navigator.clipboard.writeText(displayCode).then(() => {
736
- setCopied(true);
737
- setTimeout(() => setCopied(false), 2000);
738
- });
739
- }
740
- }, [displayCode]);
741
-
742
- if (!active) return null;
743
-
744
- // No story selected
745
- if (!currentStoryId) {
746
- return (
747
- <div style={styles.container}>
748
- <div style={styles.emptyState}>
749
- <span>Select a story to view its source code</span>
750
- </div>
751
- </div>
752
- );
753
- }
754
-
755
- // No source code available
756
- if (!sourceCode) {
757
- return (
758
- <div style={styles.container}>
759
- <div style={styles.header}>
760
- <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
761
- <span style={styles.title}>Source Code</span>
762
- </div>
763
- {isGeneratedStory && (
764
- <button
765
- style={{
766
- ...styles.deleteButton,
767
- ...(isDeleting ? styles.deleteButtonDeleting : {}),
768
- ...(deleteHover && !isDeleting ? styles.deleteButtonHover : {}),
769
- }}
770
- onClick={handleDelete}
771
- onMouseEnter={() => setDeleteHover(true)}
772
- onMouseLeave={() => setDeleteHover(false)}
773
- disabled={isDeleting}
774
- title="Delete this story file and all its variants"
775
- >
776
- {isDeleting ? 'Deleting...' : 'Delete Story'}
777
- </button>
778
- )}
779
- </div>
780
- <div style={styles.emptyState}>
781
- <span>No source code available for this story</span>
782
- <span style={styles.storyInfo}>
783
- {storyTitle || currentStoryId}
784
- </span>
785
- <span style={{ ...styles.storyInfo, marginTop: '12px', maxWidth: '280px' }}>
786
- Generate a story using the Story UI panel to see the code here.
787
- </span>
788
- </div>
789
- </div>
790
- );
791
- }
792
-
793
- return (
794
- <div style={styles.container}>
795
- <div style={styles.header}>
796
- <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
797
- <span style={styles.title}>{showFullCode ? 'Full Story' : 'Usage Code'}</span>
798
- {hasUsageCode && (
799
- <button
800
- style={{
801
- background: 'transparent',
802
- color: '#888',
803
- border: '1px solid #555',
804
- borderRadius: '4px',
805
- padding: '2px 8px',
806
- fontSize: '10px',
807
- cursor: 'pointer',
808
- fontWeight: 400,
809
- }}
810
- onClick={() => setShowFullCode(!showFullCode)}
811
- >
812
- {showFullCode ? 'Show Usage' : 'Show Full'}
813
- </button>
814
- )}
815
- </div>
816
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
817
- <button
818
- style={{
819
- ...styles.copyButton,
820
- background: copied ? '#16825D' : '#0E639C',
821
- }}
822
- onClick={handleCopy}
823
- >
824
- {copied ? 'Copied!' : 'Copy'}
825
- </button>
826
- {isGeneratedStory && (
827
- <button
828
- style={{
829
- ...styles.deleteButton,
830
- ...(isDeleting ? styles.deleteButtonDeleting : {}),
831
- ...(deleteHover && !isDeleting ? styles.deleteButtonHover : {}),
832
- }}
833
- onClick={handleDelete}
834
- onMouseEnter={() => setDeleteHover(true)}
835
- onMouseLeave={() => setDeleteHover(false)}
836
- disabled={isDeleting}
837
- title="Delete this story file and all its variants"
838
- >
839
- {isDeleting ? 'Deleting...' : 'Delete Story'}
840
- </button>
841
- )}
842
- </div>
843
- </div>
844
- <div style={styles.codeContainer}>
845
- <SyntaxHighlighter code={displayCode} />
846
- </div>
847
- </div>
848
- );
849
- };
850
-
851
- // Register the addon - always register, not just in Edge mode
852
- addons.register(ADDON_ID, () => {
853
- addons.add(PANEL_ID, {
854
- type: types.PANEL,
855
- title: 'Source Code',
856
- match: ({ viewMode }) => viewMode === 'story' || viewMode === 'docs',
857
- render: ({ active }) => <SourceCodePanel active={active} />,
858
- });
859
- });