import fs from 'fs'; import path from 'path'; /** * Vite plugin to auto-generate Liquid templates when React components are created */ export function autoLiquidGenerator() { return { name: 'auto-liquid-generator', buildStart() { console.log('π€ Auto-liquid generator initialized'); checkAllComponents(); }, handleHotUpdate({ file }: { file: string }) { if (!file.includes('src/components/') || !file.endsWith('.tsx')) { return; } const componentDir = path.dirname(file); const componentName = path.basename(file, '.tsx'); const kebabName = componentName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''); const liquidFileName = `section.${kebabName}.liquid`; const liquidFilePath = path.join(componentDir, liquidFileName); if (fs.existsSync(file)) { const shouldRegenerate = !fs.existsSync(liquidFilePath); generateLiquidTemplate(componentName, componentDir); if (shouldRegenerate) { console.log(`\nπ NEW COMPONENT DETECTED!`); console.log(`π ${componentName}.tsx β section.${kebabName}.liquid`); console.log(`β Auto-generated Liquid template!`); } else { console.log(`\nπ COMPONENT UPDATED!`); console.log(`π ${componentName}.tsx β section.${kebabName}.liquid`); console.log(`β Liquid template regenerated!`); } } } }; } /** * Analyzes React component and extracts props info */ function analyzeReactComponent(reactContent: string) { const propsMatch = reactContent.match(/interface\s+\w*Props\s*{([^}]+)}/s) || reactContent.match(/\w+:\s*React\.FC<{([^}]+)}>/s); let detectedProps: Array<{name: string, type: string, optional: boolean, default?: string}> = []; if (propsMatch) { const propsString = propsMatch[1]; const propLines = propsString.split('\n').filter(line => line.trim() && !line.trim().startsWith('//')); detectedProps = propLines.map(line => { const trimmed = line.trim(); const optional = trimmed.includes('?'); const nameMatch = trimmed.match(/(\w+)\??:/); const typeMatch = trimmed.match(/:\s*([^;]+)/); if (nameMatch) { const name = nameMatch[1]; const type = typeMatch ? typeMatch[1].trim() : 'string'; return { name, type, optional }; } return null; }).filter(Boolean) as Array<{name: string, type: string, optional: boolean}>; } // Detect component type from name and props const componentType = detectComponentType(reactContent, detectedProps); return { detectedProps, componentType }; } /** * Detects what type of component this is */ function detectComponentType(content: string, props: Array<{name: string, type: string, optional: boolean}>) { const componentName = content.toLowerCase(); const propNames = props.map(p => p.name.toLowerCase()).join(' '); if (componentName.includes('newsletter') || componentName.includes('signup') || propNames.includes('email')) { return 'newsletter'; } if (componentName.includes('banner') || componentName.includes('welcome') || componentName.includes('hero')) { return 'banner'; } if (componentName.includes('countdown') || componentName.includes('timer') || propNames.includes('targetdate')) { return 'countdown'; } if (componentName.includes('cart') || componentName.includes('summary')) { return 'cart'; } if (componentName.includes('product') && componentName.includes('gallery')) { return 'product-gallery'; } if (componentName.includes('product') && componentName.includes('quick')) { return 'product-quick-view'; } return 'generic'; } /** * Check if a file was manually edited (has custom content beyond auto-generation) */ function isManuallyEditedLiquid(filePath: string): boolean { if (!fs.existsSync(filePath)) return false; try { const content = fs.readFileSync(filePath, 'utf-8'); // 1. Check for explicit manual edit markers (highest priority) const hasManualMarker = content.includes('') || content.includes('{% comment %} MANUAL EDIT {% endcomment %}'); if (hasManualMarker) { console.log(`\x1b[91mπ [MANUAL-MARK]\x1b[0m Detected manual edit by marker: ${filePath}`); return true; } // 2. Check if file has our auto-generation signature const isAutoGenerated = content.includes('Auto-generated with Metaobject Support'); // 3. If it doesn't have our signature, it's likely manually created/edited if (!isAutoGenerated) { console.log(`\x1b[93mπ [MANUAL-CONTENT]\x1b[0m Detected manual edit (no auto-gen signature): ${filePath}`); return true; } // 4. Check for manual modifications in auto-generated files const hasManualModifications = content.includes('MODIFIED MANUALLY') || content.includes('CUSTOM LIQUID') || content.includes('manually customized') || content.includes('Manual edit') || content.includes('custom code') || // Check if standard comments were modified (content.includes('{% comment %}') && (content.includes('FOR TEST') || content.includes('MANUAL') || content.includes('Custom'))); if (hasManualModifications) { console.log(`\x1b[93mπ [MANUAL-DETECT]\x1b[0m Detected manual modifications in content: ${filePath}`); return true; } return false; } catch (error) { return false; } } /** * Automatically generates a Liquid template for a React component * RESPECTS manual edits - will not overwrite manually edited files */ function generateLiquidTemplate(componentName: string, componentDir: string) { const reactFilePath = path.join(componentDir, `${componentName}.tsx`); const kebabName = componentName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''); const liquidFileName = `section.${kebabName}.liquid`; const liquidFilePath = path.join(componentDir, liquidFileName); if (!fs.existsSync(reactFilePath)) { console.error(`β React component not found: ${reactFilePath}`); return; } // Check if the Liquid file was manually edited if (isManuallyEditedLiquid(liquidFilePath)) { console.log(`\x1b[91mπ [MANUAL]\x1b[0m Skipping auto-generation for manually edited file: ${liquidFileName}`); return; } const reactContent = fs.readFileSync(reactFilePath, 'utf-8'); const { detectedProps, componentType } = analyzeReactComponent(reactContent); const template = generateLiquidContent(componentName, kebabName, detectedProps, componentType, reactContent); // Write/overwrite only if it's not manually edited fs.writeFileSync(liquidFilePath, template, 'utf-8'); console.log(`β¨ Auto-generated/updated Liquid template: ${liquidFileName}`); } /** * Generates the Liquid template content with smart fallbacks and metaobject support */ function generateLiquidContent( componentName: string, kebabName: string, props: Array<{name: string, type: string, optional: boolean}>, componentType: string, reactContent: string ): string { // Generate settings dynamically based on detected props const settings = props.map(prop => { const lowerName = prop.name.toLowerCase(); // Metaobject detection if (lowerName.includes('product') && !lowerName.includes('title')) { return ` { "type": "product", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}" }`; } else if (lowerName.includes('collection')) { return ` { "type": "collection", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}" }`; } else if (lowerName.includes('metaobject') || lowerName.includes('meta')) { return ` { "type": "text", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()} (Metaobject Handle)", "info": "Enter the handle of the metaobject to load" }`; } else if (lowerName.includes('image') || lowerName.includes('photo') || lowerName.includes('picture')) { return ` { "type": "image_picker", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}" }`; } else if (lowerName.includes('url') || lowerName.includes('link') || lowerName.includes('href')) { return ` { "type": "url", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}" }`; } else if (lowerName.includes('date') || lowerName.includes('time')) { return ` { "type": "text", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "default": "2024-12-31T23:59:59", "info": "Format: YYYY-MM-DDTHH:mm:ss" }`; } else if (prop.type.includes('boolean') || lowerName.includes('show') || lowerName.includes('enable')) { return ` { "type": "checkbox", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "default": true }`; } else if (lowerName.includes('color')) { return ` { "type": "color", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "default": "#3b82f6" }`; } else if (lowerName.includes('title') || lowerName.includes('heading')) { return ` { "type": "text", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "default": "Sample ${prop.name}" }`; } else if (lowerName.includes('subtitle') || lowerName.includes('description') || lowerName.includes('content')) { return ` { "type": "textarea", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "default": "Sample ${prop.name.toLowerCase()}" }`; } else if (lowerName.includes('number') || lowerName.includes('count') || lowerName.includes('limit')) { return ` { "type": "number", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "default": 10 }`; } else if (lowerName.includes('range') || lowerName.includes('min') || lowerName.includes('max')) { return ` { "type": "range", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}", "min": 0, "max": 100, "step": 1, "default": 50 }`; } else { return ` { "type": "text", "id": "${prop.name.toLowerCase()}", "label": "${prop.name.replace(/([A-Z])/g, ' $1').trim()}" }`; } }).join(',\n'); // Generate liquid variables with metaobject support const liquidVariables = props.map(prop => { const lowerName = prop.name.toLowerCase(); if (lowerName.includes('metaobject') || lowerName.includes('meta')) { return `assign ${prop.name.toLowerCase()}_data = shop.metaobjects[section.settings.${prop.name.toLowerCase()}] | first assign ${prop.name.toLowerCase()} = ${prop.name.toLowerCase()}_data`; } else if (lowerName.includes('product')) { return `assign ${prop.name.toLowerCase()} = all_products[section.settings.${prop.name.toLowerCase()}]`; } else if (lowerName.includes('collection')) { return `assign ${prop.name.toLowerCase()} = collections[section.settings.${prop.name.toLowerCase()}]`; } else { return `assign ${prop.name.toLowerCase()} = section.settings.${prop.name.toLowerCase()}`; } }).join('\n '); // Generate React props with enhanced data passing const reactProps = props.map(prop => { const lowerName = prop.name.toLowerCase(); if (lowerName.includes('metaobject') || lowerName.includes('meta')) { return `"${prop.name}": {{ ${prop.name.toLowerCase()} | json }}`; } else if (lowerName.includes('product') || lowerName.includes('collection')) { return `"${prop.name}": {{ ${prop.name.toLowerCase()} | json }}`; } else { return `"${prop.name}": {{ ${prop.name.toLowerCase()} | json }}`; } }).join(',\n '); // Generate smart fallback based on component type const fallbackContent = generateSmartFallback(componentName, kebabName, props, componentType, reactContent); return `{% comment %} ${componentName} Section - Auto-generated with Metaobject Support Liquid template that renders SSR data and prepares React hydration {% endcomment %} {% schema %} { "name": "${componentName.replace(/([A-Z])/g, ' $1').trim()}", "tag": "section", "class": "${kebabName}-section", "settings": [ { "type": "header", "content": "${componentName} Settings" }${settings ? ',\n' + settings : ''} ], "presets": [ { "name": "${componentName.replace(/([A-Z])/g, ' $1').trim()}" } ] } {% endschema %} {%- liquid ${liquidVariables} -%} {%- comment -%} React Component Root {%- endcomment -%} {%- comment -%} JSON data for React - Script with data-section-data {%- endcomment -%} {%- comment -%} Smart Fallback Content - Shows if JavaScript doesn't load {%- endcomment -%} ${fallbackContent} `; } /** * Extracts Tailwind classes from React component content */ function extractTailwindClasses(reactContent: string, componentType: string): { containerClasses: string[], headingClasses: string[], textClasses: string[], backgroundClasses: string[] } { // Find all className occurrences const classNameMatches = reactContent.match(/className[=\s]*["`']([^"`']*)["`']/g) || []; let allClasses: string[] = []; classNameMatches.forEach(match => { const classesStr = match.match(/["`']([^"`']*)["`']/)?.[1] || ''; const classes = classesStr.split(/\s+/).filter(c => c.trim()); allClasses.push(...classes); }); // Template literal classes (multi-line) const templateMatches = reactContent.match(/className={\s*`([^`]*)`\s*}/gs) || []; templateMatches.forEach(match => { const classesStr = match.match(/`([^`]*)`/s)?.[1] || ''; const classes = classesStr.split(/\s+/).filter(c => c.trim() && !c.includes('$')); allClasses.push(...classes); }); // Categorize classes const containerClasses = allClasses.filter(c => c.includes('w-full') || c.includes('max-w-') || c.includes('mx-auto') || c.includes('px-') || c.includes('py-') || c.includes('p-') || c.includes('rounded-') || c.includes('shadow-') || c.includes('relative') || c.includes('overflow-hidden') ); const backgroundClasses = allClasses.filter(c => c.includes('bg-gradient-') || c.includes('from-') || c.includes('via-') || c.includes('to-') || c.includes('bg-') || c.includes('backdrop-blur') ); const headingClasses = allClasses.filter(c => c.includes('text-') && (c.includes('xl') || c.includes('lg') || c.includes('md')) || c.includes('font-') && (c.includes('black') || c.includes('bold')) ); const textClasses = allClasses.filter(c => c.includes('text-') || c.includes('leading-') || c.includes('tracking-') ); return { containerClasses: [...new Set(containerClasses)], headingClasses: [...new Set(headingClasses)], textClasses: [...new Set(textClasses)], backgroundClasses: [...new Set(backgroundClasses)] }; } /** * Converts Tailwind classes to inline CSS */ function tailwindToCSS(classes: string[]): string { const cssMap: Record = { // Layout 'w-full': 'width: 100%', 'max-w-2xl': 'max-width: 42rem', 'max-w-6xl': 'max-width: 72rem', 'max-w-7xl': 'max-width: 80rem', 'max-w-lg': 'max-width: 32rem', 'mx-auto': 'margin-left: auto; margin-right: auto', 'px-4': 'padding-left: 1rem; padding-right: 1rem', 'py-8': 'padding-top: 2rem; padding-bottom: 2rem', 'p-8': 'padding: 2rem', 'p-6': 'padding: 1.5rem', 'md:p-12': 'padding: 3rem', 'md:p-16': 'padding: 4rem', // Display 'flex': 'display: flex', 'items-center': 'align-items: center', 'justify-center': 'justify-content: center', 'text-center': 'text-align: center', 'relative': 'position: relative', 'overflow-hidden': 'overflow: hidden', // Borders & Shadows 'rounded-3xl': 'border-radius: 1.5rem', 'rounded-2xl': 'border-radius: 1rem', 'shadow-2xl': 'box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25)', 'shadow-xl': 'box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 'backdrop-blur-sm': 'backdrop-filter: blur(4px)', 'border': 'border-width: 1px', 'border-white/20': 'border-color: rgba(255, 255, 255, 0.2)', // Colors 'text-white': 'color: white', 'text-gray-800': 'color: #1f2937', 'text-gray-600': 'color: #4b5563', // Typography 'text-5xl': 'font-size: 3rem; line-height: 1', 'text-6xl': 'font-size: 3.75rem; line-height: 1', 'text-7xl': 'font-size: 4.5rem; line-height: 1', 'md:text-4xl': 'font-size: 2.25rem; line-height: 2.5rem', 'md:text-6xl': 'font-size: 3.75rem; line-height: 1', 'lg:text-7xl': 'font-size: 4.5rem; line-height: 1', 'font-black': 'font-weight: 900', 'font-bold': 'font-weight: 700', 'font-semibold': 'font-weight: 600', 'leading-tight': 'line-height: 1.25', 'leading-relaxed': 'line-height: 1.625', // Spacing 'mb-6': 'margin-bottom: 1.5rem', 'mb-8': 'margin-bottom: 2rem', 'mb-4': 'margin-bottom: 1rem', 'mt-10': 'margin-top: 2.5rem', 'mt-12': 'margin-top: 3rem' }; return classes.map(cls => cssMap[cls] || '').filter(Boolean).join('; '); } /** * Generates gradient CSS from Tailwind gradient classes */ function generateGradientCSS(backgroundClasses: string[]): string { const hasGradient = backgroundClasses.some(c => c.includes('bg-gradient-')); if (!hasGradient) return ''; const direction = backgroundClasses.find(c => c.includes('bg-gradient-'))?.includes('to-br') ? 'to bottom right' : 'to right'; const from = backgroundClasses.find(c => c.startsWith('from-')); const via = backgroundClasses.find(c => c.startsWith('via-')); const to = backgroundClasses.find(c => c.startsWith('to-')); const colorMap: Record = { 'from-blue-500': '#3b82f6', 'from-blue-600': '#2563eb', 'via-blue-600': '#2563eb', 'via-purple-600': '#7c3aed', 'to-purple-700': '#7c3aed', 'to-pink-600': '#ec4899', 'from-emerald-500': '#10b981', 'via-teal-600': '#0d9488', 'to-cyan-700': '#0e7490' }; let stops = []; if (from) stops.push(colorMap[from] || '#3b82f6'); if (via) stops.push(colorMap[via] || '#7c3aed'); if (to) stops.push(colorMap[to] || '#ec4899'); if (stops.length === 0) stops = ['#3b82f6', '#7c3aed', '#ec4899']; return `background: linear-gradient(${direction}, ${stops.join(', ')})`; } /** * Generates smart fallback content that matches React component styles */ function generateSmartFallback( componentName: string, kebabName: string, props: Array<{name: string, type: string, optional: boolean}>, componentType: string, reactContent: string ): string { try { const titleProp = props.find(p => p.name.toLowerCase().includes('title')); const subtitleProp = props.find(p => p.name.toLowerCase().includes('subtitle')); // Generate fallback based on actual React styles for specific components if (componentName === 'WelcomeBanner') { return ` π {% if title %}{{ title }}{% else %}Welcome to Reactpify!{% endif %} {% if subtitle %} {{ subtitle }} {% endif %} β¨ React component loading... `; } if (componentName === 'NewsletterSignup') { return ` π {% if title %}{{ title }}{% else %}Stay in the Loop!{% endif %} {% if subtitle %} {{ subtitle }} {% endif %} Subscribe Now! π β¨ React component loading... `; } // Default fallback for other components return ` β¨ {% if title %}{{ title }}{% else %}${componentName}{% endif %} {% if subtitle %} {{ subtitle }} {% endif %} π React component loading... `; } catch (error) { console.error(`β [ERROR] generateSmartFallback failed for ${componentName}:`, error); // Fallback bΓ‘sico en caso de error return ` β οΈ ${componentName} π React component loading... `; } } /** * Check all existing components to generate missing Liquid templates * and regenerate existing ones to keep them up to date */ function checkAllComponents() { const componentsDir = 'src/components'; if (!fs.existsSync(componentsDir)) { console.log('π Components directory not found, creating...'); fs.mkdirSync(componentsDir, { recursive: true }); return; } const componentFolders = fs.readdirSync(componentsDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const folderName of componentFolders) { const componentDir = path.join(componentsDir, folderName); const files = fs.readdirSync(componentDir); // Find React component files const reactFiles = files.filter(file => file.endsWith('.tsx') && !file.includes('.test.') && !file.includes('.stories.')); for (const reactFile of reactFiles) { const componentName = path.basename(reactFile, '.tsx'); const kebabName = componentName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''); const liquidFileName = `section.${kebabName}.liquid`; const liquidExists = files.includes(liquidFileName); if (!liquidExists) { console.log(`π Found React component without Liquid template: ${componentName}`); generateLiquidTemplate(componentName, componentDir); } else { // Always regenerate to keep templates up to date with any changes console.log(`π Regenerating existing template: ${componentName}`); generateLiquidTemplate(componentName, componentDir); } } } }
{{ subtitle }}
β¨ React component loading...
π React component loading...