#!/usr/bin/env node /** * Button V2 Migration Codemod * * This script migrates N8nButton components from the legacy V1 API to the V2 API. * * Transformations: * - type="primary" → variant="solid" * - type="secondary" → variant="subtle" * - type="tertiary" → variant="subtle" * - type="danger" → variant="destructive" * - type="success" → variant="solid" class="n8n-button--success" * - type="warning" → variant="solid" class="n8n-button--warning" * - type="highlight" → variant="ghost" class="n8n-button--highlight" * - type="highlightFill" → variant="subtle" class="n8n-button--highlightFill" * - outline prop → variant="outline" * - text prop → variant="ghost" * - size="xmini"|"mini" → size="xsmall" * - square → iconOnly * - nativeType → type attribute * - block → style="width: 100%" * - element="a" → (removed, href determines element) * Usage: * node migrate-button-v2.mjs [--dry-run] */ import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; import { join, relative } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Configuration const FRONTEND_ROOT = join(__dirname, '../../..'); const DRY_RUN = process.argv.includes('--dry-run'); // Mapping from legacy type to new variant const TYPE_TO_VARIANT = { primary: 'solid', secondary: 'subtle', tertiary: 'subtle', danger: 'destructive', }; // Legacy types that need override classes const LEGACY_TYPES_WITH_CLASSES = { success: { variant: 'solid', className: 'n8n-button--success' }, warning: { variant: 'solid', className: 'n8n-button--warning' }, highlight: { variant: 'ghost', className: 'n8n-button--highlight' }, highlightFill: { variant: 'subtle', className: 'n8n-button--highlightFill' }, }; // Size normalization const SIZE_MAP = { xmini: 'xsmall', mini: 'xsmall', }; // Stats const stats = { filesScanned: 0, filesModified: 0, transformations: { typeToVariant: 0, legacyTypeWithClass: 0, outlineToVariant: 0, textToVariant: 0, sizeNormalized: 0, squareToIconOnly: 0, nativeTypeToType: 0, blockToStyle: 0, elementRemoved: 0, }, }; /** * Find all .vue files recursively */ function findVueFiles(dir, files = []) { const entries = readdirSync(dir); for (const entry of entries) { const fullPath = join(dir, entry); // Skip node_modules and hidden directories if (entry === 'node_modules' || entry.startsWith('.')) continue; const stat = statSync(fullPath); if (stat.isDirectory()) { findVueFiles(fullPath, files); } else if (entry.endsWith('.vue')) { files.push(fullPath); } } return files; } /** * Transform a single N8nButton tag */ function transformButtonTag(fullMatch, tagContent, selfClosing, content, closingTag) { let modified = false; const changes = []; // Track what we need to add let newVariant = null; let addClass = null; let addStyle = null; // Parse current attributes (handles both static and v-bind shorthand :prop) const hasType = /\btype=["']([^"']+)["']/.exec(tagContent); const hasVariant = /\bvariant=["']/.test(tagContent); const hasOutline = /\b:?outline(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent); const hasText = /\b:?text(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent); const hasSize = /\b:?size=["']([^"']+)["']/.exec(tagContent); const hasSquare = /\b:?square(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent); const hasNativeType = /\b:?nativeType=["']([^"']+)["']/.exec(tagContent); const hasBlock = /\b:?block(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent); const hasElement = /\b:?element=["']([^"']+)["']/.exec(tagContent); const hasClass = /\bclass=["']([^"']+)["']/.exec(tagContent); const hasStyle = /\bstyle=["']([^"']+)["']/.exec(tagContent); // Skip if already using variant (already migrated) if (hasVariant) { return fullMatch; } // 1. Handle outline prop → variant="outline" if (hasOutline && !hasText) { newVariant = 'outline'; tagContent = tagContent.replace(/\s*\b:?outline(?:=["']true["'])?(?=\s|\/?>)/, ''); // Also remove type if present since outline takes precedence if (hasType) { tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, ''); } changes.push('outline → variant="outline"'); stats.transformations.outlineToVariant++; modified = true; } // 2. Handle text prop → variant="ghost" else if (hasText) { newVariant = 'ghost'; tagContent = tagContent.replace(/\s*\b:?text(?:=["']true["'])?(?=\s|\/?>)/, ''); // Also remove type and outline if present if (hasType) { tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, ''); } if (hasOutline) { tagContent = tagContent.replace(/\s*\b:?outline(?:=["']true["'])?(?=\s|\/?>)/, ''); } changes.push('text → variant="ghost"'); stats.transformations.textToVariant++; modified = true; } // 3. Handle type prop else if (hasType) { const typeValue = hasType[1]; if (TYPE_TO_VARIANT[typeValue]) { // Direct mapping newVariant = TYPE_TO_VARIANT[typeValue]; tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, ''); changes.push(`type="${typeValue}" → variant="${newVariant}"`); stats.transformations.typeToVariant++; modified = true; } else if (LEGACY_TYPES_WITH_CLASSES[typeValue]) { // Legacy type with class override const mapping = LEGACY_TYPES_WITH_CLASSES[typeValue]; newVariant = mapping.variant; addClass = mapping.className; tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, ''); changes.push(`type="${typeValue}" → variant="${newVariant}" + class="${addClass}"`); stats.transformations.legacyTypeWithClass++; modified = true; } } // 4. Handle size normalization if (hasSize && SIZE_MAP[hasSize[1]]) { const oldSize = hasSize[1]; const newSize = SIZE_MAP[oldSize]; tagContent = tagContent.replace(/\b:?size=["'][^"']+["']/, `size="${newSize}"`); changes.push(`size="${oldSize}" → size="${newSize}"`); stats.transformations.sizeNormalized++; modified = true; } // 5. Handle square → iconOnly if (hasSquare) { tagContent = tagContent.replace(/\s*\b:?square(?:=["']true["'])?(?=\s|\/?>)/, ''); // Add iconOnly attribute tagContent = tagContent.replace( /(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/, '$1 iconOnly', ); changes.push('square → iconOnly'); stats.transformations.squareToIconOnly++; modified = true; } // 6. Handle nativeType → type if (hasNativeType) { const nativeTypeValue = hasNativeType[1]; tagContent = tagContent.replace(/\s*\b:?nativeType=["'][^"']+["']/, ''); tagContent = tagContent.replace( /(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/, `$1 type="${nativeTypeValue}"`, ); changes.push(`nativeType="${nativeTypeValue}" → type="${nativeTypeValue}"`); stats.transformations.nativeTypeToType++; modified = true; } // 7. Handle block → style="width: 100%" if (hasBlock) { tagContent = tagContent.replace(/\s*\b:?block(?:=["']true["'])?(?=\s|\/?>)/, ''); addStyle = 'width: 100%'; changes.push('block → style="width: 100%"'); stats.transformations.blockToStyle++; modified = true; } // 8. Handle element="a" → remove (href determines element) if (hasElement) { tagContent = tagContent.replace(/\s*\b:?element=["'][^"']+["']/, ''); changes.push('element removed (href determines element)'); stats.transformations.elementRemoved++; modified = true; } if (!modified) { return fullMatch; } // Now apply the collected changes const BUTTON_TAG_PATTERN = /(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/; // Add variant attribute if (newVariant) { tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 variant="${newVariant}"`); } // Merge class attribute if (addClass) { if (hasClass) { // Merge with existing class tagContent = tagContent.replace(/\bclass=["']([^"']+)["']/, `class="$1 ${addClass}"`); } else { // Add new class attribute tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 class="${addClass}"`); } } // Merge style attribute if (addStyle) { if (hasStyle) { // Merge with existing style const existingStyle = hasStyle[1].trim(); const separator = existingStyle.endsWith(';') ? ' ' : '; '; tagContent = tagContent.replace( /\bstyle=["']([^"']+)["']/, `style="$1${separator}${addStyle}"`, ); } else { // Add new style attribute tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 style="${addStyle}"`); } } // Build the new tag let result; if (selfClosing) { result = `<${tagContent.trim()} />`; } else { result = `<${tagContent.trim()}>${content || ''}${closingTag}`; } // Log the transformation console.log(` ${changes.join(', ')}`); return result; } /** * Transform all N8nButton usages in a file */ function transformFile(filePath) { const content = readFileSync(filePath, 'utf-8'); stats.filesScanned++; // Find template section - use greedy match ([\s\S]*) to get the outermost // Vue SFC files may have nested