@tpitre/story-ui 4.13.0 → 4.13.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.
@@ -1,730 +0,0 @@
1
- /**
2
- * Convert Voice Canvas HTML to Storybook Story (Hybrid Mode)
3
- *
4
- * Generates a hybrid story file with two exports:
5
- * - Reference: iframe-based render of the exact voice canvas HTML (100% visual fidelity, no LLM)
6
- * - Default: LLM-generated component-based implementation using the project's design system
7
- *
8
- * Unlike dumping HTML into the prompt field, this endpoint:
9
- * - Parses the HTML structure and extracts semantic intent
10
- * - Maps inline styles to design system tokens/props
11
- * - Generates component-based code using actual imports
12
- * - Preserves visual fidelity through structural constraints
13
- * - Provides a pixel-perfect reference via iframe for comparison
14
- */
15
- import * as cheerio from 'cheerio';
16
- import * as crypto from 'crypto';
17
- import { loadUserConfig } from '../../story-generator/configLoader.js';
18
- import { generateStory as saveStory } from '../../story-generator/generateStory.js';
19
- import { logger } from '../../story-generator/logger.js';
20
- import { getProviderRegistry, initializeFromEnv, } from '../../story-generator/llm-providers/index.js';
21
- import { EnhancedComponentDiscovery } from '../../story-generator/enhancedComponentDiscovery.js';
22
- import { detectProjectFramework, } from '../../story-generator/promptGenerator.js';
23
- // --- Design system token mappings ---
24
- /** Common design system spacing scales (px -> token name) */
25
- const SPACING_MAP = {
26
- 2: 'xxs', 4: 'xs', 8: 'sm', 12: 'md', 16: 'md', 20: 'lg', 24: 'lg',
27
- 32: 'xl', 40: 'xl', 48: '2xl', 64: '3xl', 80: '4xl', 96: '5xl',
28
- };
29
- /** Common font sizes (px -> token name) */
30
- const FONT_SIZE_MAP = {
31
- 10: 'xs', 11: 'xs', 12: 'sm', 13: 'sm', 14: 'md', 15: 'md', 16: 'md',
32
- 18: 'lg', 20: 'xl', 24: '2xl', 28: '2xl', 30: '3xl', 32: '3xl',
33
- 36: '4xl', 40: '4xl', 48: '5xl',
34
- };
35
- /** Common border radii (px -> token name) */
36
- const RADIUS_MAP = {
37
- 0: 'none', 2: 'xs', 4: 'sm', 6: 'md', 8: 'md', 10: 'lg', 12: 'lg',
38
- 16: 'xl', 20: 'xl', 24: '2xl', 9999: 'full',
39
- };
40
- /** Font weight names */
41
- const WEIGHT_MAP = {
42
- 100: 'thin', 200: 'extralight', 300: 'light', 400: 'normal',
43
- 500: 'medium', 600: 'semibold', 700: 'bold', 800: 'extrabold', 900: 'black',
44
- };
45
- /** Known dark background colors (hex, lowercased) */
46
- const DARK_BG_COLORS = [
47
- '#0d1117', '#1a1b2e', '#111111', '#0a0a0a', '#121212', '#1e1e1e',
48
- '#181818', '#1a1a2e', '#16213e', '#0f0f0f', '#0b0d17', '#1c1c1c',
49
- '#212121', '#2d2d2d', '#1f1f1f', '#0e0e10', '#18181b',
50
- ];
51
- /** Parse a hex color to RGB values */
52
- function hexToRgb(hex) {
53
- const clean = hex.replace('#', '');
54
- let r, g, b;
55
- if (clean.length === 3) {
56
- r = parseInt(clean[0] + clean[0], 16);
57
- g = parseInt(clean[1] + clean[1], 16);
58
- b = parseInt(clean[2] + clean[2], 16);
59
- }
60
- else if (clean.length === 6) {
61
- r = parseInt(clean.slice(0, 2), 16);
62
- g = parseInt(clean.slice(2, 4), 16);
63
- b = parseInt(clean.slice(4, 6), 16);
64
- }
65
- else {
66
- return null;
67
- }
68
- return { r, g, b };
69
- }
70
- /** Map a hex color to the nearest design system semantic color */
71
- function mapColorToToken(hex) {
72
- const rgb = hexToRgb(hex);
73
- if (!rgb)
74
- return { token: hex, confidence: 'low' };
75
- const { r, g, b } = rgb;
76
- // Detect common color categories
77
- const brightness = (r * 299 + g * 587 + b * 114) / 1000;
78
- const saturation = Math.max(r, g, b) - Math.min(r, g, b);
79
- // Near-white
80
- if (brightness > 240 && saturation < 20)
81
- return { token: 'white', confidence: 'high' };
82
- // Near-black
83
- if (brightness < 30 && saturation < 20)
84
- return { token: 'dark.9', confidence: 'high' };
85
- // Grays (low saturation)
86
- if (saturation < 30) {
87
- if (brightness > 200)
88
- return { token: 'gray.1', confidence: 'high' };
89
- if (brightness > 160)
90
- return { token: 'gray.3', confidence: 'high' };
91
- if (brightness > 120)
92
- return { token: 'gray.5', confidence: 'high' };
93
- if (brightness > 80)
94
- return { token: 'gray.7', confidence: 'high' };
95
- return { token: 'gray.9', confidence: 'high' };
96
- }
97
- // Blues (common for primary)
98
- if (b > r && b > g && b > 120) {
99
- if (b > 200 && r < 100)
100
- return { token: 'blue.5 (primary)', confidence: 'medium' };
101
- if (b > 150)
102
- return { token: 'blue.7', confidence: 'medium' };
103
- return { token: 'blue.9', confidence: 'medium' };
104
- }
105
- // Greens (common for success)
106
- if (g > r && g > b && g > 120) {
107
- return { token: 'green.6 (success)', confidence: 'medium' };
108
- }
109
- // Reds (common for error/danger)
110
- if (r > g && r > b && r > 150) {
111
- return { token: 'red.6 (error)', confidence: 'medium' };
112
- }
113
- // Yellows/oranges (common for warning)
114
- if (r > 180 && g > 120 && b < 100) {
115
- return { token: 'yellow.6 (warning)', confidence: 'medium' };
116
- }
117
- // Purple
118
- if (r > 100 && b > 100 && g < 100) {
119
- return { token: 'violet.6', confidence: 'medium' };
120
- }
121
- return { token: hex, confidence: 'low' };
122
- }
123
- /** Parse CSS value to pixel number */
124
- function parsePx(value) {
125
- const match = value.match(/(\d+(?:\.\d+)?)\s*px/);
126
- return match ? parseFloat(match[1]) : null;
127
- }
128
- /** Map inline CSS values to design system tokens */
129
- function mapStylesToTokens(html) {
130
- const $ = cheerio.load(html, { xml: false });
131
- const mappings = [];
132
- const seen = new Set();
133
- $('[style]').each((_, el) => {
134
- const style = $(el).attr('style') || '';
135
- // Colors
136
- const colorMatches = style.match(/#[0-9a-f]{3,8}/gi) || [];
137
- for (const color of colorMatches) {
138
- if (seen.has(`color:${color}`))
139
- continue;
140
- seen.add(`color:${color}`);
141
- const mapped = mapColorToToken(color);
142
- mappings.push({
143
- cssValue: color,
144
- tokenType: 'color',
145
- suggestedToken: mapped.token,
146
- confidence: mapped.confidence,
147
- });
148
- }
149
- // Spacing (padding, margin, gap)
150
- const spacingProps = style.match(/(?:padding|margin|gap)(?:-(?:top|right|bottom|left))?:\s*([^;]+)/gi) || [];
151
- for (const prop of spacingProps) {
152
- const px = parsePx(prop);
153
- if (px !== null && !seen.has(`spacing:${px}`)) {
154
- seen.add(`spacing:${px}`);
155
- const nearest = Object.keys(SPACING_MAP).map(Number).sort((a, b) => Math.abs(a - px) - Math.abs(b - px))[0];
156
- if (nearest !== undefined) {
157
- mappings.push({
158
- cssValue: `${px}px`,
159
- tokenType: 'spacing',
160
- suggestedToken: SPACING_MAP[nearest],
161
- confidence: px === nearest ? 'high' : 'medium',
162
- });
163
- }
164
- }
165
- }
166
- // Font sizes
167
- const fontSizeMatch = style.match(/font-size:\s*([^;]+)/i);
168
- if (fontSizeMatch) {
169
- const px = parsePx(fontSizeMatch[1]);
170
- if (px !== null && !seen.has(`fontSize:${px}`)) {
171
- seen.add(`fontSize:${px}`);
172
- const nearest = Object.keys(FONT_SIZE_MAP).map(Number).sort((a, b) => Math.abs(a - px) - Math.abs(b - px))[0];
173
- if (nearest !== undefined) {
174
- mappings.push({
175
- cssValue: `${px}px`,
176
- tokenType: 'fontSize',
177
- suggestedToken: FONT_SIZE_MAP[nearest],
178
- confidence: px === nearest ? 'high' : 'medium',
179
- });
180
- }
181
- }
182
- }
183
- // Font weight
184
- const weightMatch = style.match(/font-weight:\s*(\d+)/i);
185
- if (weightMatch) {
186
- const w = parseInt(weightMatch[1]);
187
- if (!seen.has(`weight:${w}`)) {
188
- seen.add(`weight:${w}`);
189
- if (WEIGHT_MAP[w]) {
190
- mappings.push({
191
- cssValue: String(w),
192
- tokenType: 'fontWeight',
193
- suggestedToken: WEIGHT_MAP[w],
194
- confidence: 'high',
195
- });
196
- }
197
- }
198
- }
199
- // Border radius
200
- const radiusMatch = style.match(/border-radius:\s*([^;]+)/i);
201
- if (radiusMatch) {
202
- const px = parsePx(radiusMatch[1]);
203
- if (px !== null && !seen.has(`radius:${px}`)) {
204
- seen.add(`radius:${px}`);
205
- const nearest = Object.keys(RADIUS_MAP).map(Number).sort((a, b) => Math.abs(a - px) - Math.abs(b - px))[0];
206
- if (nearest !== undefined) {
207
- mappings.push({
208
- cssValue: `${px}px`,
209
- tokenType: 'radius',
210
- suggestedToken: RADIUS_MAP[nearest],
211
- confidence: px === nearest ? 'high' : 'medium',
212
- });
213
- }
214
- }
215
- }
216
- });
217
- return mappings;
218
- }
219
- /** Detect whether the HTML uses a dark theme based on background colors */
220
- function detectDarkTheme(html) {
221
- const $ = cheerio.load(html, { xml: false });
222
- // Check body-level and root-level background styles
223
- const allStyles = [];
224
- $('body, [style]').each((_, el) => {
225
- const style = $(el).attr('style') || '';
226
- if (style)
227
- allStyles.push(style.toLowerCase());
228
- });
229
- const allStyleText = allStyles.join(' ');
230
- // Check for known dark background hex colors
231
- for (const darkColor of DARK_BG_COLORS) {
232
- if (allStyleText.includes(darkColor))
233
- return true;
234
- }
235
- // Check for dark background via rgb values
236
- const bgColorMatches = allStyleText.match(/background(?:-color)?:\s*([^;]+)/gi) || [];
237
- for (const bgMatch of bgColorMatches) {
238
- const hexColors = bgMatch.match(/#[0-9a-f]{3,8}/gi) || [];
239
- for (const hex of hexColors) {
240
- const rgb = hexToRgb(hex);
241
- if (rgb) {
242
- const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
243
- if (brightness < 50)
244
- return true;
245
- }
246
- }
247
- // Check rgb() values
248
- const rgbMatch = bgMatch.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
249
- if (rgbMatch) {
250
- const r = parseInt(rgbMatch[1]);
251
- const g = parseInt(rgbMatch[2]);
252
- const b = parseInt(rgbMatch[3]);
253
- const brightness = (r * 299 + g * 587 + b * 114) / 1000;
254
- if (brightness < 50)
255
- return true;
256
- }
257
- }
258
- return false;
259
- }
260
- /** Extract background gradients from HTML styles */
261
- function extractGradients(html) {
262
- const $ = cheerio.load(html, { xml: false });
263
- const gradients = [];
264
- const seen = new Set();
265
- $('[style]').each((_, el) => {
266
- const style = $(el).attr('style') || '';
267
- const gradientMatches = style.match(/(?:linear|radial|conic)-gradient\([^)]+(?:\([^)]*\))*[^)]*\)/gi) || [];
268
- for (const gradient of gradientMatches) {
269
- if (!seen.has(gradient)) {
270
- seen.add(gradient);
271
- gradients.push(gradient);
272
- }
273
- }
274
- });
275
- return gradients;
276
- }
277
- /** Analyze the HTML structure to build conversion constraints */
278
- function analyzeHtml(html) {
279
- const $ = cheerio.load(html, { xml: false });
280
- // Extract sections
281
- const sections = [];
282
- $('[data-section]').each((_, el) => {
283
- sections.push($(el).attr('data-section') || 'unknown');
284
- });
285
- // Extract unique element types
286
- const elements = new Set();
287
- $('*').each((_, el) => {
288
- const tag = el.tagName;
289
- if (tag && !['html', 'head', 'body', 'div', 'span'].includes(tag)) {
290
- elements.add(tag);
291
- }
292
- });
293
- // Extract inline style patterns
294
- const colors = new Set();
295
- const spacing = new Set();
296
- const typography = new Set();
297
- $('[style]').each((_, el) => {
298
- const style = $(el).attr('style') || '';
299
- // Colors
300
- const colorMatches = style.match(/#[0-9a-f]{3,8}|rgb[a]?\([^)]+\)/gi);
301
- if (colorMatches)
302
- colorMatches.forEach(c => colors.add(c));
303
- // Spacing
304
- const spacingMatches = style.match(/(?:padding|margin|gap):\s*[^;]+/gi);
305
- if (spacingMatches)
306
- spacingMatches.forEach(s => spacing.add(s.trim()));
307
- // Typography
308
- const fontMatches = style.match(/font-(?:size|weight|family):\s*[^;]+/gi);
309
- if (fontMatches)
310
- fontMatches.forEach(f => typography.add(f.trim()));
311
- });
312
- // Detect layout type
313
- let layout = 'vertical';
314
- $('[style]').each((_, el) => {
315
- const style = $(el).attr('style') || '';
316
- if (style.includes('grid'))
317
- layout = 'grid';
318
- else if (style.includes('flex-direction: row') || style.includes('flex-direction:row'))
319
- layout = 'horizontal';
320
- });
321
- // Map inline styles to design system tokens
322
- const tokenMappings = mapStylesToTokens(html);
323
- // Detect dark theme
324
- const isDarkTheme = detectDarkTheme(html);
325
- // Extract gradients
326
- const backgroundGradients = extractGradients(html);
327
- return {
328
- sections,
329
- elements: Array.from(elements),
330
- layout,
331
- colors: Array.from(colors).slice(0, 10),
332
- spacing: Array.from(spacing).slice(0, 10),
333
- typography: Array.from(typography).slice(0, 5),
334
- tokenMappings,
335
- isDarkTheme,
336
- backgroundGradients,
337
- };
338
- }
339
- /** Build a constraint document that guides the story generation LLM */
340
- function buildConstraintDocument(html, analysis, designSystem) {
341
- const $ = cheerio.load(html, { xml: false });
342
- // Extract text content for each section to preserve copy
343
- const sectionContents = [];
344
- if (analysis.sections.length > 0) {
345
- $('[data-section]').each((_, el) => {
346
- const name = $(el).attr('data-section');
347
- const texts = [];
348
- $(el).find('h1, h2, h3, h4, h5, h6, p, span, a, button, label, li').each((_, textEl) => {
349
- const text = $(textEl).text().trim();
350
- if (text)
351
- texts.push(text);
352
- });
353
- if (name && texts.length > 0) {
354
- sectionContents.push(` - ${name}: ${texts.join(' | ')}`);
355
- }
356
- });
357
- }
358
- // Dark theme rules
359
- const darkThemeRules = analysis.isDarkTheme
360
- ? `
361
- DARK THEME DETECTED:
362
- - The source HTML uses a dark color scheme. You MUST wrap the entire Implementation component in a MantineProvider with defaultColorScheme="dark" and forceColorScheme="dark".
363
- - Example: <MantineProvider defaultColorScheme="dark" forceColorScheme="dark">...</MantineProvider>
364
- - Import MantineProvider from ${designSystem === 'Mantine' ? '@mantine/core' : 'the design system provider'}.
365
- - All text colors should be light (gray.1, gray.2, white) to contrast with dark backgrounds.
366
- `
367
- : '';
368
- // Gradient rules
369
- const gradientRules = analysis.backgroundGradients.length > 0
370
- ? `
371
- GRADIENT PRESERVATION:
372
- - The source HTML contains the following background gradients — preserve them as inline styles. Do NOT simplify gradients to flat colors:
373
- ${analysis.backgroundGradients.map(g => ` - ${g}`).join('\n')}
374
- `
375
- : '';
376
- return `VOICE CANVAS CONVERSION — STRICT FIDELITY RULES
377
-
378
- SOURCE HTML (the user created this via voice, it MUST be preserved exactly):
379
- \`\`\`html
380
- ${html}
381
- \`\`\`
382
-
383
- STRUCTURAL ANALYSIS:
384
- - Layout: ${analysis.layout}
385
- - Sections: ${analysis.sections.length > 0 ? analysis.sections.join(', ') : 'unsectioned'}
386
- - Element types: ${analysis.elements.join(', ')}
387
- - Color palette: ${analysis.colors.join(', ') || 'default theme'}
388
- - Theme: ${analysis.isDarkTheme ? 'DARK' : 'light'}
389
- ${darkThemeRules}
390
- TEXT CONTENT (must appear exactly as shown):
391
- ${sectionContents.length > 0 ? sectionContents.join('\n') : ' (extract all text from the HTML above)'}
392
-
393
- TOKEN MAPPING TABLE (use these exact mappings):
394
- ${analysis.tokenMappings.length > 0 ? analysis.tokenMappings.map(m => ` ${m.cssValue} -> ${m.suggestedToken} (${m.tokenType}, ${m.confidence} confidence)`).join('\n') : ' (no mappings — use design system defaults)'}
395
- ${gradientRules}
396
- CONVERSION RULES:
397
- 1. PRESERVE EXACT TEXT — every heading, paragraph, label, and button text must match the source HTML character-for-character.
398
- 2. PRESERVE LAYOUT STRUCTURE — if the source has 3 columns, the story must have 3 columns. If it has a header above content, keep that order.
399
- 3. USE ${designSystem.toUpperCase()} COMPONENTS — map HTML elements to the closest design system component:
400
- - <button> -> Button component
401
- - <input> -> TextInput/Input component
402
- - <img> -> Image component
403
- - <h1-h6> -> Title/Heading component with appropriate level
404
- - <p> -> Text component
405
- - Cards, containers -> Card/Paper component
406
- - Grid layouts -> Grid/SimpleGrid component
407
- 4. USE TOKEN MAPPINGS — apply the token mapping table above. For "high confidence" mappings, use the token directly. For "medium", use your best judgment. For "low" confidence, use the ORIGINAL CSS value as an inline style instead of guessing a token.
408
- 5. MAP SPACING TO SCALE — use the spacing tokens from the mapping table instead of pixel values.
409
- 6. DO NOT ADD ELEMENTS — do not add content, sections, or UI elements that aren't in the source HTML.
410
- 7. DO NOT REMOVE ELEMENTS — every visible element in the source must appear in the story.
411
- 8. DO NOT CHANGE COPY — the text content is final. Do not rephrase, shorten, or "improve" any text.
412
- 9. PRESERVE GRADIENTS — background gradients must be kept as inline styles exactly as they appear in the source. Do NOT flatten a gradient to a single solid color.
413
- 10. LOW CONFIDENCE FALLBACK — when a token mapping has "low" confidence, use the original CSS value as an inline style (e.g., style={{ backgroundColor: '#1a1b2e' }}) rather than guessing a design system token.`;
414
- }
415
- /** Escape backticks in HTML for embedding in a template literal */
416
- function escapeForTemplateLiteral(html) {
417
- return html.replace(/`/g, '\\`').replace(/\$/g, '\\$');
418
- }
419
- /** Build the deterministic story file shell around the LLM-generated Implementation JSX */
420
- function buildHybridStoryFile(opts) {
421
- const { implementationJsx, escapedHtml, framework, importPath, storyPrefix, title, isDarkTheme, designSystem, } = opts;
422
- const storybookFramework = framework === 'react' ? 'react' : framework;
423
- // Build the iframe reference CSS shell (matches VoiceCanvas rendering environment)
424
- const voiceHtmlLiteral = `\`<!DOCTYPE html>
425
- <html><head><style>
426
- *, *::before, *::after { box-sizing: border-box; }
427
- body { margin: 0; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.6; color: #c9d1d9; background: #0d1117; min-height: 100vh; }
428
- img { max-width: 100%; height: auto; display: block; }
429
- a { color: #58a6ff; text-decoration: none; }
430
- </style></head><body>${escapedHtml}</body></html>\``;
431
- // Only add MantineProvider import if the Implementation doesn't already import from the design system
432
- const implHasDesignSystemImport = implementationJsx.includes(`from '${importPath}'`);
433
- return `import React from 'react';
434
- import type { Meta, StoryObj } from '@storybook/${storybookFramework}';
435
- ${implHasDesignSystemImport ? '' : `// Design system imports are included in the Implementation component below`}
436
-
437
- const VOICE_HTML = ${voiceHtmlLiteral};
438
-
439
- const VoiceReference = () => (
440
- <iframe
441
- srcDoc={VOICE_HTML}
442
- style={{ width: '100%', height: '600px', border: 'none' }}
443
- title="Voice Canvas Reference"
444
- />
445
- );
446
-
447
- ${implementationJsx}
448
-
449
- const meta: Meta = {
450
- title: '${storyPrefix}${title}',
451
- parameters: { layout: 'fullscreen' },
452
- };
453
- export default meta;
454
- type Story = StoryObj;
455
-
456
- export const Default: Story = {
457
- render: () => <Implementation />,
458
- };
459
-
460
- export const Reference: Story = {
461
- render: () => <VoiceReference />,
462
- parameters: {
463
- docs: {
464
- description: {
465
- story: 'Exact voice canvas output \\u2014 visual reference',
466
- },
467
- },
468
- },
469
- };
470
- `;
471
- }
472
- function getProvider(providerType) {
473
- let initialized = false;
474
- if (!initialized) {
475
- initializeFromEnv();
476
- initialized = true;
477
- }
478
- const registry = getProviderRegistry();
479
- if (providerType) {
480
- const provider = registry.get(providerType);
481
- if (provider?.isConfigured())
482
- return provider;
483
- }
484
- const configured = registry.getConfiguredProviders();
485
- if (configured.length > 0)
486
- return configured[0];
487
- throw new Error('No LLM provider configured');
488
- }
489
- export async function convertToStory(req, res) {
490
- const { html, designSystem: requestedDs, provider: providerType, model: requestedModel, title: requestedTitle, } = req.body;
491
- if (!html) {
492
- res.status(400).json({ error: 'html is required' });
493
- return;
494
- }
495
- // SSE headers
496
- res.writeHead(200, {
497
- 'Content-Type': 'text/event-stream',
498
- 'Cache-Control': 'no-cache',
499
- Connection: 'keep-alive',
500
- 'Access-Control-Allow-Origin': '*',
501
- });
502
- const sendEvent = (event, data) => {
503
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
504
- };
505
- try {
506
- // Detect design system
507
- let ds = requestedDs || 'Mantine';
508
- let config = null;
509
- try {
510
- config = loadUserConfig();
511
- if (!requestedDs && config?.importPath) {
512
- if (config.importPath.includes('mantine'))
513
- ds = 'Mantine';
514
- else if (config.importPath.includes('vuetify'))
515
- ds = 'Vuetify';
516
- else if (config.importPath.includes('chakra'))
517
- ds = 'Chakra UI';
518
- else if (config.importPath.includes('mui') || config.importPath.includes('material'))
519
- ds = 'Material UI';
520
- else if (config.importPath.includes('flowbite'))
521
- ds = 'Flowbite';
522
- else if (config.importPath.includes('shoelace'))
523
- ds = 'Shoelace';
524
- }
525
- }
526
- catch {
527
- // Use default
528
- }
529
- sendEvent('status', { phase: 'analyzing', message: 'Analyzing voice canvas HTML...' });
530
- // Analyze the HTML structure
531
- const analysis = analyzeHtml(html);
532
- // Build constraint document
533
- const constraintDoc = buildConstraintDocument(html, analysis, ds);
534
- sendEvent('status', { phase: 'discovering', message: 'Discovering components...' });
535
- // Discover available components
536
- let componentList = '';
537
- try {
538
- if (config) {
539
- const discovery = new EnhancedComponentDiscovery(config);
540
- const components = await discovery.discoverAll();
541
- if (components.length > 0) {
542
- componentList = components.map((c) => c.name).join(', ');
543
- }
544
- }
545
- }
546
- catch {
547
- componentList = '';
548
- }
549
- // Detect framework
550
- const framework = config?.componentFramework || detectProjectFramework() || 'react';
551
- const importPath = config?.importPath || '@mantine/core';
552
- const importStyle = config?.importStyle || 'barrel';
553
- const storyPrefix = config?.storyPrefix || 'Generated/';
554
- sendEvent('status', { phase: 'converting', message: 'Converting to Storybook story...' });
555
- const provider = getProvider(providerType);
556
- const model = requestedModel || provider.getConfig().model;
557
- // Generate a title from the HTML content
558
- const titleFromHtml = requestedTitle || generateTitleFromHtml(html);
559
- // Dark theme wrapping instructions for the LLM
560
- const darkThemeInstructions = analysis.isDarkTheme && ds === 'Mantine'
561
- ? `
562
- CRITICAL — DARK THEME: The source HTML uses a dark background. You MUST wrap all JSX inside the Implementation component with:
563
- <MantineProvider defaultColorScheme="dark" forceColorScheme="dark">
564
- ...your component JSX...
565
- </MantineProvider>
566
- Import MantineProvider from '${importPath}' at the top of your code block.`
567
- : '';
568
- const systemPrompt = `You are a Storybook component generator. Convert the provided HTML into a React component named "Implementation" using ${ds} components.
569
-
570
- FRAMEWORK: ${framework}
571
- IMPORT PATH: ${importPath}
572
- IMPORT STYLE: ${importStyle}
573
- AVAILABLE COMPONENTS: ${componentList || 'Use standard ' + ds + ' components'}
574
-
575
- OUTPUT FORMAT:
576
- - Return ONLY the Implementation component definition and its required imports.
577
- - The component must be a named function: const Implementation = () => ( ... );
578
- - Include all necessary import statements at the top (design system components, React hooks, etc.)
579
- - Do NOT include Meta, StoryObj, or story exports — those are handled externally.
580
- - Do NOT include the VOICE_HTML constant or VoiceReference component — those are handled externally.
581
- - Do NOT wrap your response in markdown code fences.
582
- - Use TypeScript.
583
- ${darkThemeInstructions}
584
-
585
- ${constraintDoc}`;
586
- const messages = [
587
- {
588
- role: 'user',
589
- content: `Convert this voice-generated HTML into an Implementation component. Return ONLY the imports and the "const Implementation = () => (...)" definition. Follow the constraint document in the system prompt exactly.`,
590
- },
591
- ];
592
- // Stream the response
593
- let fullResponse = '';
594
- const startTime = Date.now();
595
- if (!provider.chatStream) {
596
- const response = await provider.chat(messages, {
597
- model,
598
- maxTokens: 8192,
599
- temperature: 0.2,
600
- systemPrompt,
601
- });
602
- fullResponse = response.content;
603
- sendEvent('story_chunk', { content: fullResponse });
604
- }
605
- else {
606
- const stream = provider.chatStream(messages, {
607
- model,
608
- maxTokens: 8192,
609
- temperature: 0.2,
610
- systemPrompt,
611
- });
612
- for await (const chunk of stream) {
613
- if (chunk.type === 'text' && chunk.content) {
614
- fullResponse += chunk.content;
615
- sendEvent('story_chunk', { content: chunk.content });
616
- }
617
- else if (chunk.type === 'error') {
618
- sendEvent('error', { message: chunk.error });
619
- break;
620
- }
621
- else if (chunk.type === 'done') {
622
- // done
623
- }
624
- }
625
- }
626
- const elapsed = Date.now() - startTime;
627
- // Clean up markdown fences from LLM output
628
- let implementationJsx = fullResponse.trim();
629
- if (implementationJsx.startsWith('```tsx') || implementationJsx.startsWith('```typescript')) {
630
- implementationJsx = implementationJsx.replace(/^```\w*\n?/, '');
631
- }
632
- else if (implementationJsx.startsWith('```')) {
633
- implementationJsx = implementationJsx.slice(3);
634
- }
635
- if (implementationJsx.endsWith('```')) {
636
- implementationJsx = implementationJsx.slice(0, -3);
637
- }
638
- implementationJsx = implementationJsx.trim();
639
- // Remove imports that the shell already provides (React, Meta, StoryObj)
640
- // The shell imports: React, Meta, StoryObj, and optionally MantineProvider
641
- implementationJsx = implementationJsx.replace(/^import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]react['"];\s*\n?/gm, '');
642
- implementationJsx = implementationJsx.replace(/^import\s+(?:type\s+)?\{[^}]*(?:Meta|StoryObj|StoryFn)[^}]*\}\s+from\s+['"]@storybook\/[^'"]+['"];\s*\n?/gm, '');
643
- // Remove duplicate export default meta, type Story, export const
644
- implementationJsx = implementationJsx.replace(/^(?:export\s+default\s+meta\s*;|type\s+Story\s*=\s*StoryObj[^;]*;|export\s+const\s+\w+:\s*Story\s*=\s*\{[^}]*\};\s*)\n?/gm, '');
645
- // Remove const meta: Meta = { ... }; export default meta; blocks
646
- implementationJsx = implementationJsx.replace(/^const\s+meta:\s*Meta[^=]*=\s*\{[^}]*\};\s*\n?/gm, '');
647
- // Escape HTML for the template literal
648
- const escapedHtml = escapeForTemplateLiteral(html);
649
- // Build the complete hybrid story file deterministically
650
- const cleanStory = buildHybridStoryFile({
651
- implementationJsx,
652
- escapedHtml,
653
- framework,
654
- importPath,
655
- storyPrefix,
656
- title: titleFromHtml,
657
- isDarkTheme: analysis.isDarkTheme,
658
- designSystem: ds,
659
- });
660
- // Save the story to disk
661
- const storyId = `voice-${crypto.randomBytes(4).toString('hex')}`;
662
- const safeTitle = titleFromHtml.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-');
663
- const fileName = `${safeTitle}-${storyId}.stories.tsx`;
664
- let savedPath = '';
665
- try {
666
- if (config) {
667
- savedPath = saveStory({ fileContents: cleanStory, fileName, config });
668
- sendEvent('status', { phase: 'saved', message: `Story saved: ${fileName}` });
669
- logger.log(`Voice story saved to: ${savedPath}`);
670
- }
671
- }
672
- catch (saveErr) {
673
- logger.error('Failed to save voice story:', saveErr instanceof Error ? saveErr.message : String(saveErr));
674
- // Don't fail the whole request — still return the story code
675
- }
676
- sendEvent('complete', {
677
- story: cleanStory,
678
- title: titleFromHtml,
679
- fileName,
680
- storyId,
681
- savedPath,
682
- sourceHtml: html,
683
- analysis: {
684
- sections: analysis.sections,
685
- layout: analysis.layout,
686
- elementCount: analysis.elements.length,
687
- isDarkTheme: analysis.isDarkTheme,
688
- },
689
- metrics: {
690
- timeMs: elapsed,
691
- model,
692
- provider: provider.type,
693
- },
694
- });
695
- logger.log(`Voice-to-story conversion: ${elapsed}ms using ${provider.type}/${model}`);
696
- }
697
- catch (error) {
698
- const message = error instanceof Error ? error.message : String(error);
699
- logger.error('Convert-to-story error:', message);
700
- sendEvent('error', { message });
701
- }
702
- finally {
703
- res.end();
704
- }
705
- }
706
- /** Generate a human-readable title from HTML content */
707
- function generateTitleFromHtml(html) {
708
- const $ = cheerio.load(html, { xml: false });
709
- // Try to find a heading
710
- const h1 = $('h1').first().text().trim();
711
- if (h1)
712
- return sanitizeTitle(h1);
713
- const h2 = $('h2').first().text().trim();
714
- if (h2)
715
- return sanitizeTitle(h2);
716
- // Try data-section attributes
717
- const sections = $('[data-section]').map((_, el) => $(el).attr('data-section')).get();
718
- if (sections.length > 0) {
719
- return sections.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
720
- }
721
- return 'Voice Generated';
722
- }
723
- function sanitizeTitle(text) {
724
- return text
725
- .replace(/[^a-zA-Z0-9\s-]/g, '')
726
- .trim()
727
- .split(/\s+/)
728
- .slice(0, 5)
729
- .join(' ');
730
- }