real-prototypes-skill 0.1.0 → 0.1.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.
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * HTML-to-React Conversion Pipeline
5
+ *
6
+ * Converts captured HTML files into React/JSX components while
7
+ * preserving exact class names, styles, and structure.
8
+ *
9
+ * Features:
10
+ * - Parses HTML using jsdom
11
+ * - Extracts component tree structure
12
+ * - Converts HTML attributes to JSX (class→className, for→htmlFor)
13
+ * - Preserves inline styles as objects
14
+ * - Identifies semantic component boundaries
15
+ * - Generates React component files
16
+ *
17
+ * Usage:
18
+ * node html-to-react.js --project <name> --page <page>
19
+ * node html-to-react.js --input ./html/page.html --output ./components
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { JSDOM } = require('jsdom');
25
+
26
+ // HTML attributes that need transformation for JSX
27
+ const JSX_ATTR_MAP = {
28
+ 'class': 'className',
29
+ 'for': 'htmlFor',
30
+ 'tabindex': 'tabIndex',
31
+ 'readonly': 'readOnly',
32
+ 'maxlength': 'maxLength',
33
+ 'minlength': 'minLength',
34
+ 'cellpadding': 'cellPadding',
35
+ 'cellspacing': 'cellSpacing',
36
+ 'rowspan': 'rowSpan',
37
+ 'colspan': 'colSpan',
38
+ 'usemap': 'useMap',
39
+ 'frameborder': 'frameBorder',
40
+ 'contenteditable': 'contentEditable',
41
+ 'crossorigin': 'crossOrigin',
42
+ 'datetime': 'dateTime',
43
+ 'enctype': 'encType',
44
+ 'formaction': 'formAction',
45
+ 'formenctype': 'formEncType',
46
+ 'formmethod': 'formMethod',
47
+ 'formnovalidate': 'formNoValidate',
48
+ 'formtarget': 'formTarget',
49
+ 'hreflang': 'hrefLang',
50
+ 'inputmode': 'inputMode',
51
+ 'srcdoc': 'srcDoc',
52
+ 'srcset': 'srcSet',
53
+ 'autocomplete': 'autoComplete',
54
+ 'autofocus': 'autoFocus',
55
+ 'autoplay': 'autoPlay'
56
+ };
57
+
58
+ // Self-closing tags in JSX
59
+ const SELF_CLOSING_TAGS = new Set([
60
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
61
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
62
+ ]);
63
+
64
+ // Tags to skip (scripts, etc.)
65
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'link', 'meta']);
66
+
67
+ class HTMLToReactConverter {
68
+ constructor(options = {}) {
69
+ this.options = {
70
+ preserveComments: false,
71
+ generateTypeScript: true,
72
+ componentPrefix: '',
73
+ wrapWithFragment: true,
74
+ extractComponents: true,
75
+ minComponentDepth: 2,
76
+ ...options
77
+ };
78
+
79
+ this.dom = null;
80
+ this.document = null;
81
+ this.extractedComponents = [];
82
+ this.componentCounter = 0;
83
+ }
84
+
85
+ /**
86
+ * Load HTML from file
87
+ */
88
+ loadFromFile(htmlPath) {
89
+ if (!fs.existsSync(htmlPath)) {
90
+ throw new Error(`HTML file not found: ${htmlPath}`);
91
+ }
92
+ const html = fs.readFileSync(htmlPath, 'utf-8');
93
+ return this.loadFromString(html);
94
+ }
95
+
96
+ /**
97
+ * Load HTML from string
98
+ */
99
+ loadFromString(html) {
100
+ this.dom = new JSDOM(html);
101
+ this.document = this.dom.window.document;
102
+ return this;
103
+ }
104
+
105
+ /**
106
+ * Parse inline style string to object
107
+ */
108
+ parseStyleString(styleStr) {
109
+ if (!styleStr) return null;
110
+
111
+ const styleObj = {};
112
+ const rules = styleStr.split(';').filter(Boolean);
113
+
114
+ for (const rule of rules) {
115
+ const [property, value] = rule.split(':').map(s => s.trim());
116
+ if (property && value) {
117
+ // Convert CSS property to camelCase
118
+ const camelProperty = property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
119
+ styleObj[camelProperty] = value;
120
+ }
121
+ }
122
+
123
+ return Object.keys(styleObj).length > 0 ? styleObj : null;
124
+ }
125
+
126
+ /**
127
+ * Format style object as JSX string
128
+ */
129
+ formatStyleObject(styleObj) {
130
+ if (!styleObj) return null;
131
+
132
+ const entries = Object.entries(styleObj)
133
+ .map(([key, value]) => {
134
+ // Wrap string values in quotes, keep numbers as-is
135
+ const formattedValue = typeof value === 'number' ? value : `"${value}"`;
136
+ return `${key}: ${formattedValue}`;
137
+ });
138
+
139
+ return `{{ ${entries.join(', ')} }}`;
140
+ }
141
+
142
+ /**
143
+ * Convert HTML element to JSX string
144
+ */
145
+ elementToJSX(element, indent = 0) {
146
+ const indentStr = ' '.repeat(indent);
147
+
148
+ // Handle text nodes
149
+ if (element.nodeType === 3) { // TEXT_NODE
150
+ const text = element.textContent.trim();
151
+ if (!text) return '';
152
+ // Escape JSX special characters
153
+ return text
154
+ .replace(/\{/g, '&#123;')
155
+ .replace(/\}/g, '&#125;');
156
+ }
157
+
158
+ // Handle comment nodes
159
+ if (element.nodeType === 8) { // COMMENT_NODE
160
+ if (!this.options.preserveComments) return '';
161
+ return `${indentStr}{/* ${element.textContent.trim()} */}`;
162
+ }
163
+
164
+ // Skip non-element nodes
165
+ if (element.nodeType !== 1) return ''; // ELEMENT_NODE
166
+
167
+ const tagName = element.tagName.toLowerCase();
168
+
169
+ // Skip certain tags
170
+ if (SKIP_TAGS.has(tagName)) return '';
171
+
172
+ // Build attributes
173
+ const attrs = this.buildAttributes(element);
174
+ const attrsStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
175
+
176
+ // Handle self-closing tags
177
+ if (SELF_CLOSING_TAGS.has(tagName)) {
178
+ return `${indentStr}<${tagName}${attrsStr} />`;
179
+ }
180
+
181
+ // Handle children
182
+ const children = Array.from(element.childNodes)
183
+ .map(child => this.elementToJSX(child, indent + 1))
184
+ .filter(Boolean);
185
+
186
+ // If no children
187
+ if (children.length === 0) {
188
+ // Check for text content
189
+ const textContent = element.textContent.trim();
190
+ if (textContent) {
191
+ return `${indentStr}<${tagName}${attrsStr}>${textContent}</${tagName}>`;
192
+ }
193
+ return `${indentStr}<${tagName}${attrsStr} />`;
194
+ }
195
+
196
+ // If single text child
197
+ if (children.length === 1 && !children[0].includes('\n') && children[0].length < 60) {
198
+ return `${indentStr}<${tagName}${attrsStr}>${children[0].trim()}</${tagName}>`;
199
+ }
200
+
201
+ // Multi-line
202
+ return [
203
+ `${indentStr}<${tagName}${attrsStr}>`,
204
+ ...children,
205
+ `${indentStr}</${tagName}>`
206
+ ].join('\n');
207
+ }
208
+
209
+ /**
210
+ * Build JSX attributes from element
211
+ */
212
+ buildAttributes(element) {
213
+ const attrs = [];
214
+
215
+ for (const attr of element.attributes) {
216
+ let name = attr.name.toLowerCase();
217
+ let value = attr.value;
218
+
219
+ // Skip event handlers
220
+ if (name.startsWith('on')) continue;
221
+
222
+ // Skip data attributes that look like framework-specific
223
+ if (name.startsWith('data-react') || name.startsWith('data-v-')) continue;
224
+
225
+ // Transform attribute name
226
+ name = JSX_ATTR_MAP[name] || name;
227
+
228
+ // Handle special attributes
229
+ if (name === 'style') {
230
+ const styleObj = this.parseStyleString(value);
231
+ if (styleObj) {
232
+ attrs.push(`style={${JSON.stringify(styleObj)}}`);
233
+ }
234
+ continue;
235
+ }
236
+
237
+ // Handle boolean attributes
238
+ if (value === '' || value === name || value === 'true') {
239
+ attrs.push(name);
240
+ continue;
241
+ }
242
+
243
+ // Handle className (might have dynamic parts)
244
+ if (name === 'className') {
245
+ attrs.push(`className="${value}"`);
246
+ continue;
247
+ }
248
+
249
+ // Regular attribute
250
+ attrs.push(`${name}="${value}"`);
251
+ }
252
+
253
+ return attrs;
254
+ }
255
+
256
+ /**
257
+ * Extract semantic component boundaries
258
+ */
259
+ extractComponentBoundaries() {
260
+ const boundaries = [];
261
+
262
+ // Look for semantic elements
263
+ const semanticSelectors = [
264
+ 'header', 'nav', 'main', 'aside', 'footer', 'article', 'section',
265
+ '[role="banner"]', '[role="navigation"]', '[role="main"]',
266
+ '[role="complementary"]', '[role="contentinfo"]'
267
+ ];
268
+
269
+ for (const selector of semanticSelectors) {
270
+ const elements = this.document.querySelectorAll(selector);
271
+ for (const el of elements) {
272
+ boundaries.push({
273
+ type: el.tagName.toLowerCase(),
274
+ role: el.getAttribute('role'),
275
+ id: el.id,
276
+ className: el.className,
277
+ element: el,
278
+ suggestedName: this.suggestComponentName(el)
279
+ });
280
+ }
281
+ }
282
+
283
+ // Also look for common class patterns
284
+ const classPatterns = [
285
+ { pattern: /header/i, name: 'Header' },
286
+ { pattern: /nav(bar|igation)?/i, name: 'Navigation' },
287
+ { pattern: /sidebar/i, name: 'Sidebar' },
288
+ { pattern: /footer/i, name: 'Footer' },
289
+ { pattern: /card/i, name: 'Card' },
290
+ { pattern: /modal/i, name: 'Modal' },
291
+ { pattern: /table/i, name: 'DataTable' },
292
+ { pattern: /form/i, name: 'Form' },
293
+ { pattern: /button/i, name: 'Button' },
294
+ { pattern: /panel/i, name: 'Panel' },
295
+ { pattern: /container/i, name: 'Container' }
296
+ ];
297
+
298
+ // Find elements by class patterns
299
+ const allElements = this.document.querySelectorAll('*[class]');
300
+ for (const el of allElements) {
301
+ const className = el.className;
302
+ if (typeof className !== 'string') continue;
303
+
304
+ for (const { pattern, name } of classPatterns) {
305
+ if (pattern.test(className)) {
306
+ // Avoid duplicates
307
+ const exists = boundaries.some(b =>
308
+ b.element === el || (b.className === className && b.type === el.tagName.toLowerCase())
309
+ );
310
+ if (!exists) {
311
+ boundaries.push({
312
+ type: el.tagName.toLowerCase(),
313
+ id: el.id,
314
+ className: className,
315
+ element: el,
316
+ suggestedName: name
317
+ });
318
+ }
319
+ break;
320
+ }
321
+ }
322
+ }
323
+
324
+ return boundaries;
325
+ }
326
+
327
+ /**
328
+ * Suggest component name from element
329
+ */
330
+ suggestComponentName(element) {
331
+ // Try ID first
332
+ if (element.id) {
333
+ return this.toPascalCase(element.id);
334
+ }
335
+
336
+ // Try main class
337
+ const className = element.className;
338
+ if (typeof className === 'string' && className) {
339
+ const mainClass = className.split(' ')[0];
340
+ return this.toPascalCase(mainClass);
341
+ }
342
+
343
+ // Try tag name
344
+ const tagName = element.tagName.toLowerCase();
345
+ const semanticMap = {
346
+ 'header': 'Header',
347
+ 'nav': 'Navigation',
348
+ 'main': 'MainContent',
349
+ 'aside': 'Sidebar',
350
+ 'footer': 'Footer',
351
+ 'article': 'Article',
352
+ 'section': 'Section'
353
+ };
354
+
355
+ return semanticMap[tagName] || `Component${++this.componentCounter}`;
356
+ }
357
+
358
+ /**
359
+ * Convert string to PascalCase
360
+ */
361
+ toPascalCase(str) {
362
+ return str
363
+ .replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
364
+ .replace(/^(.)/, c => c.toUpperCase())
365
+ .replace(/[^a-zA-Z0-9]/g, '');
366
+ }
367
+
368
+ /**
369
+ * Convert full document body to JSX
370
+ */
371
+ convertBody() {
372
+ const body = this.document.body;
373
+ if (!body) return '';
374
+
375
+ return this.elementToJSX(body, 0);
376
+ }
377
+
378
+ /**
379
+ * Convert specific element to JSX
380
+ */
381
+ convertElement(selector) {
382
+ const element = this.document.querySelector(selector);
383
+ if (!element) return null;
384
+
385
+ return this.elementToJSX(element, 0);
386
+ }
387
+
388
+ /**
389
+ * Generate React component from element
390
+ */
391
+ generateComponent(element, componentName) {
392
+ const jsx = this.elementToJSX(element, 2);
393
+ const ext = this.options.generateTypeScript ? 'tsx' : 'jsx';
394
+ const propsType = this.options.generateTypeScript ? ': React.FC' : '';
395
+
396
+ const component = `import React from 'react';
397
+
398
+ export const ${componentName}${propsType} = () => {
399
+ return (
400
+ ${jsx}
401
+ );
402
+ };
403
+
404
+ export default ${componentName};
405
+ `;
406
+
407
+ return {
408
+ name: componentName,
409
+ filename: `${componentName}.${ext}`,
410
+ content: component,
411
+ jsx: jsx
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Extract and generate all components
417
+ */
418
+ extractAllComponents() {
419
+ const boundaries = this.extractComponentBoundaries();
420
+ const components = [];
421
+
422
+ for (const boundary of boundaries) {
423
+ if (!boundary.element) continue;
424
+
425
+ const componentName = boundary.suggestedName;
426
+ const component = this.generateComponent(boundary.element, componentName);
427
+ components.push(component);
428
+ }
429
+
430
+ return components;
431
+ }
432
+
433
+ /**
434
+ * Generate page component that imports all extracted components
435
+ */
436
+ generatePageComponent(pageName, components) {
437
+ const ext = this.options.generateTypeScript ? 'tsx' : 'jsx';
438
+ const propsType = this.options.generateTypeScript ? ': React.FC' : '';
439
+
440
+ const imports = components
441
+ .map(c => `import { ${c.name} } from './${c.name}';`)
442
+ .join('\n');
443
+
444
+ const componentUsage = components
445
+ .map(c => ` <${c.name} />`)
446
+ .join('\n');
447
+
448
+ const page = `import React from 'react';
449
+ ${imports}
450
+
451
+ export const ${pageName}${propsType} = () => {
452
+ return (
453
+ <div>
454
+ ${componentUsage}
455
+ </div>
456
+ );
457
+ };
458
+
459
+ export default ${pageName};
460
+ `;
461
+
462
+ return {
463
+ name: pageName,
464
+ filename: `${pageName}.${ext}`,
465
+ content: page
466
+ };
467
+ }
468
+
469
+ /**
470
+ * Get component tree structure
471
+ */
472
+ getComponentTree() {
473
+ const body = this.document.body;
474
+ if (!body) return null;
475
+
476
+ const buildTree = (element, depth = 0) => {
477
+ if (element.nodeType !== 1) return null;
478
+
479
+ const tagName = element.tagName.toLowerCase();
480
+ if (SKIP_TAGS.has(tagName)) return null;
481
+
482
+ return {
483
+ tag: tagName,
484
+ id: element.id || null,
485
+ className: typeof element.className === 'string' ? element.className : null,
486
+ children: Array.from(element.children)
487
+ .map(child => buildTree(child, depth + 1))
488
+ .filter(Boolean)
489
+ };
490
+ };
491
+
492
+ return buildTree(body);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Convert HTML file to React components
498
+ */
499
+ function convertHTMLToReact(htmlPath, options = {}) {
500
+ const converter = new HTMLToReactConverter(options);
501
+ converter.loadFromFile(htmlPath);
502
+
503
+ return {
504
+ fullJSX: converter.convertBody(),
505
+ components: converter.extractAllComponents(),
506
+ tree: converter.getComponentTree(),
507
+ boundaries: converter.extractComponentBoundaries()
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Write components to output directory
513
+ */
514
+ function writeComponents(components, outputDir) {
515
+ if (!fs.existsSync(outputDir)) {
516
+ fs.mkdirSync(outputDir, { recursive: true });
517
+ }
518
+
519
+ const written = [];
520
+
521
+ for (const component of components) {
522
+ const filePath = path.join(outputDir, component.filename);
523
+ fs.writeFileSync(filePath, component.content);
524
+ written.push(filePath);
525
+ }
526
+
527
+ return written;
528
+ }
529
+
530
+ // CLI execution
531
+ if (require.main === module) {
532
+ const args = process.argv.slice(2);
533
+ let inputPath = null;
534
+ let outputDir = null;
535
+ let projectName = null;
536
+ let pageName = null;
537
+ let showTree = false;
538
+
539
+ for (let i = 0; i < args.length; i++) {
540
+ switch (args[i]) {
541
+ case '--input':
542
+ case '-i':
543
+ inputPath = args[++i];
544
+ break;
545
+ case '--output':
546
+ case '-o':
547
+ outputDir = args[++i];
548
+ break;
549
+ case '--project':
550
+ projectName = args[++i];
551
+ break;
552
+ case '--page':
553
+ pageName = args[++i];
554
+ break;
555
+ case '--tree':
556
+ showTree = true;
557
+ break;
558
+ case '--help':
559
+ case '-h':
560
+ console.log(`
561
+ Usage: node html-to-react.js [options]
562
+
563
+ Options:
564
+ --input, -i <path> Path to HTML file
565
+ --output, -o <path> Output directory for components
566
+ --project <name> Project name
567
+ --page <name> Page name (used with --project)
568
+ --tree Show component tree only
569
+ --help, -h Show this help
570
+
571
+ Examples:
572
+ node html-to-react.js --input ./html/page.html --output ./components
573
+ node html-to-react.js --project my-app --page account-detail
574
+ node html-to-react.js --input ./page.html --tree
575
+ `);
576
+ process.exit(0);
577
+ }
578
+ }
579
+
580
+ // Handle project-based paths
581
+ if (projectName) {
582
+ const SKILL_DIR = path.dirname(__dirname);
583
+ const PROJECTS_DIR = path.resolve(SKILL_DIR, '../../../projects');
584
+ const projectDir = path.join(PROJECTS_DIR, projectName);
585
+
586
+ if (pageName) {
587
+ inputPath = path.join(projectDir, 'references', 'html', `${pageName}.html`);
588
+ }
589
+
590
+ outputDir = outputDir || path.join(projectDir, 'prototype', 'src', 'components', 'extracted');
591
+ }
592
+
593
+ if (!inputPath) {
594
+ console.error('\x1b[31mError:\x1b[0m --input is required');
595
+ process.exit(1);
596
+ }
597
+
598
+ try {
599
+ console.log(`\n\x1b[1mHTML to React Converter\x1b[0m`);
600
+ console.log(`Input: ${inputPath}`);
601
+
602
+ const result = convertHTMLToReact(inputPath);
603
+
604
+ if (showTree) {
605
+ console.log(`\n\x1b[1mComponent Tree:\x1b[0m`);
606
+ console.log(JSON.stringify(result.tree, null, 2));
607
+ process.exit(0);
608
+ }
609
+
610
+ console.log(`\n\x1b[1mDetected Component Boundaries (${result.boundaries.length}):\x1b[0m`);
611
+ for (const boundary of result.boundaries.slice(0, 10)) {
612
+ console.log(` ${boundary.suggestedName} (${boundary.type}${boundary.className ? '.' + boundary.className.split(' ')[0] : ''})`);
613
+ }
614
+ if (result.boundaries.length > 10) {
615
+ console.log(` ... and ${result.boundaries.length - 10} more`);
616
+ }
617
+
618
+ console.log(`\n\x1b[1mExtracted Components (${result.components.length}):\x1b[0m`);
619
+ for (const component of result.components) {
620
+ console.log(` ${component.name} (${component.filename})`);
621
+ }
622
+
623
+ if (outputDir) {
624
+ console.log(`\n\x1b[1mWriting to:\x1b[0m ${outputDir}`);
625
+ const written = writeComponents(result.components, outputDir);
626
+ console.log(`\x1b[32m✓ Wrote ${written.length} component files\x1b[0m`);
627
+
628
+ for (const file of written) {
629
+ console.log(` ${path.basename(file)}`);
630
+ }
631
+ } else {
632
+ console.log(`\n\x1b[33mTip:\x1b[0m Use --output <dir> to write component files`);
633
+ }
634
+
635
+ } catch (error) {
636
+ console.error(`\x1b[31mError:\x1b[0m ${error.message}`);
637
+ process.exit(1);
638
+ }
639
+ }
640
+
641
+ module.exports = {
642
+ HTMLToReactConverter,
643
+ convertHTMLToReact,
644
+ writeComponents
645
+ };