diff --git a/packages/frontend/@n8n/design-system/scripts/migrate-button-v2.mjs b/packages/frontend/@n8n/design-system/scripts/migrate-button-v2.mjs new file mode 100755 index 00000000000..05a0533d701 --- /dev/null +++ b/packages/frontend/@n8n/design-system/scripts/migrate-button-v2.mjs @@ -0,0 +1,418 @@ +#!/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