From 7ab7911c2fbb1752270e7550e108b9f06e07d806 Mon Sep 17 00:00:00 2001 From: Rob Hough Date: Fri, 13 Feb 2026 10:48:03 +0000 Subject: [PATCH] refactor(editor): Migrate Button v2 to N8nButton (no-changelog) (#24947) --- .../scripts/migrate-button-v2.mjs | 418 ++++++++++++ .../AskAssistantChat/AskAssistantChat.vue | 4 +- .../AskAssistantChat.test.ts.snap | 26 +- .../messages/ErrorMessage.vue | 4 +- .../messages/MessageRating.vue | 14 +- .../messages/RestoreVersionConfirm.vue | 2 +- .../AskAssistantChat/messages/TextMessage.vue | 5 +- .../__snapshots__/MessageRating.test.ts.snap | 4 +- .../CanvasThinkingPill/CanvasThinkingPill.vue | 4 +- .../src/components/CodeDiff/CodeDiff.vue | 8 +- .../__snapshots__/CodeDiff.test.ts.snap | 39 +- .../DateRangePicker/DateRangePicker.test.ts | 6 +- .../DateRangePicker/DateRangePicker.vue | 22 +- .../components/N8nActionBox/ActionBox.test.ts | 4 +- .../src/components/N8nActionBox/ActionBox.vue | 6 +- .../N8nActionDropdown/ActionDropdown.vue | 3 +- .../N8nAlertDialog/AlertDialog.test.ts | 8 +- .../components/N8nAlertDialog/AlertDialog.vue | 4 +- .../components/N8nButton/Button.legacy.scss | 48 ++ .../src/components/N8nButton/Button.scss | 285 -------- .../components/N8nButton/Button.stories.ts | 364 ++++++---- .../src/components/N8nButton/Button.test.ts | 440 +++++++++++- .../src/components/N8nButton/Button.vue | 642 ++++++++++-------- .../__snapshots__/Button.test.ts.snap | 27 +- .../__snapshots__/Datatable.test.ts.snap | 20 +- .../N8nHeaderAction/HeaderAction.vue | 5 +- .../components/N8nIconButton/IconButton.vue | 9 +- .../components/N8nIconPicker/IconPicker.vue | 8 +- .../__snapshots__/N8nPromptInput.test.ts.snap | 47 +- .../N8nSendStopButton.test.ts | 55 +- .../N8nSendStopButton/N8nSendStopButton.vue | 8 +- .../TableHeaderControlsButton.vue | 2 +- .../TableHeaderControlsButton.test.ts.snap | 10 +- .../@n8n/design-system/src/types/button.ts | 65 +- .../v2/components/Button/Button.stories.ts | 221 ------ .../src/v2/components/Button/Button.test.ts | 424 ------------ .../src/v2/components/Button/Button.vue | 365 ---------- .../v2/components/Button/component-button.md | 190 ------ .../editor-ui/src/app/components/Banner.vue | 3 +- .../CredentialResolverEditModal.vue | 4 +- .../components/DuplicateWorkflowDialog.vue | 2 +- .../app/components/FreeAiCreditsCallout.vue | 2 +- .../app/components/ImportWorkflowUrlModal.vue | 4 +- .../WorkflowHeaderDraftPublishActions.vue | 4 +- .../MainHeader/WorkflowPublishModal.vue | 2 +- .../src/app/components/MainSidebarHeader.vue | 11 +- .../components/MainSidebarSourceControl.vue | 6 +- .../app/components/MainSidebarUserArea.vue | 2 +- .../app/components/NodeExecuteButton.test.ts | 2 +- .../src/app/components/NodeExecuteButton.vue | 6 +- .../src/app/components/NpsSurvey.vue | 4 +- .../src/app/components/SaveButton.vue | 10 +- .../components/StopManyExecutionsModal.vue | 2 +- .../src/app/components/UpdatesPanel.vue | 3 +- .../components/WorkflowDescriptionModal.vue | 4 +- .../WorkflowExtractionNameModal.vue | 2 +- .../src/app/components/WorkflowSettings.vue | 3 +- .../app/components/WorkflowShareModal.ee.vue | 2 +- .../components/common/SelectedItemsInfo.vue | 4 +- .../forms/ResourceFiltersDropdown.vue | 2 +- .../components/layouts/EmptyStateLayout.vue | 2 +- .../src/app/views/EntityNotFound.vue | 2 +- .../editor-ui/src/app/views/NodeView.vue | 4 +- .../src/app/views/OAuthConsentView.vue | 4 +- .../editor-ui/src/app/views/WorkflowsView.vue | 6 +- .../components/EmptyStateBuilderPrompt.vue | 6 +- .../components/FeaturedSandboxCard.vue | 2 +- .../components/QuickStartCard.vue | 4 +- .../components/TemplateCard.vue | 4 +- .../Agent/NotificationPermissionBanner.vue | 4 +- .../components/Agent/PlanQuestionsMessage.vue | 2 +- .../Chat/NewAssistantSessionModal.vue | 2 +- .../ai/chatHub/ChatPersonalAgentsView.vue | 2 +- .../src/features/ai/chatHub/ChatView.vue | 2 +- .../chatHub/components/AgentEditorModal.vue | 6 +- .../ai/chatHub/components/ChatAgentCard.vue | 5 +- .../components/ChatConversationHeader.vue | 7 +- .../ai/chatHub/components/ChatMessage.vue | 8 +- .../chatHub/components/ChatMessageActions.vue | 23 +- .../ai/chatHub/components/ChatPrompt.vue | 9 +- .../chatHub/components/ChatProvidersTable.vue | 4 +- .../ai/chatHub/components/ChatSidebarLink.vue | 3 +- .../ai/chatHub/components/ChatStarter.vue | 4 +- .../ai/chatHub/components/CopyButton.vue | 3 +- .../components/CredentialSelectorModal.vue | 4 +- .../components/ModelByIdSelectorModal.vue | 4 +- .../ai/chatHub/components/ModelSelector.vue | 2 +- .../components/ProviderSettingsModal.vue | 8 +- .../ai/chatHub/components/ToolsSelector.vue | 2 +- .../chatHub/components/ToolsSelectorModal.vue | 6 +- .../components/SetupWizard/SetupWizard.vue | 12 +- .../evaluation.ee/views/EvaluationsView.vue | 4 +- .../evaluation.ee/views/TestRunDetailView.vue | 2 +- .../features/ai/mcpAccess/SettingsMCPView.vue | 6 +- .../ai/mcpAccess/components/MCPEmptyState.vue | 2 +- .../connectPopover/ConnectionParameter.vue | 4 +- .../MCPAccessTokenPopoverTab.vue | 8 +- .../connectPopover/McpConnectPopover.vue | 2 +- .../components/tabs/OAuthClientsTable.vue | 2 +- .../components/tabs/WorkflowsTable.vue | 2 +- .../modals/MCPConnectWorkflowsModal.vue | 4 +- .../components/ProjectCreateResource.vue | 6 +- .../components/ProjectDeleteDialog.vue | 4 +- .../projects/components/ProjectHeader.vue | 1 + .../components/ProjectMoveResourceModal.vue | 4 +- .../components/ProjectRoleUpgradeDialog.vue | 4 +- .../projects/components/ProjectSharing.vue | 4 +- .../projects/views/ProjectSettings.vue | 6 +- .../projects/views/ProjectVariables.vue | 4 +- .../core/auth/components/MfaSetupModal.vue | 2 +- .../src/features/core/auth/views/MfaView.vue | 2 +- .../core/auth/views/SettingsPersonalView.vue | 4 +- .../components/AddDataTableModal.vue | 4 +- .../components/dataGrid/AddColumnButton.vue | 7 +- .../components/dataGrid/AddRowButton.vue | 3 +- .../components/dataGrid/ColumnHeader.vue | 3 +- .../folders/components/MoveToFolderModal.vue | 2 +- .../CredentialEdit/CredentialEdit.vue | 2 +- .../CredentialPicker/CredentialPicker.vue | 10 +- .../components/ExecutionsFilter.vue | 6 +- .../global/GlobalExecutionsListItem.vue | 4 +- .../components/workflow/VoteButtons.vue | 6 +- .../WorkflowExecutionAnnotationPanel.ee.vue | 2 +- .../WorkflowExecutionAnnotationTags.ee.vue | 10 +- .../WorkflowExecutionsLandingPage.vue | 2 +- .../workflow/WorkflowExecutionsPreview.vue | 10 +- .../components/InsightsDashboard.test.ts | 26 +- .../components/InsightsDataRangePicker.vue | 4 +- .../insights/components/InsightsPaywall.vue | 2 +- .../components/InsightsUpgradeModal.vue | 4 +- .../logs/components/ChatMessagesPanel.vue | 17 +- .../logs/components/LogDetailsPanel.vue | 8 +- .../logs/components/LogsOverviewPanel.vue | 4 +- .../logs/components/LogsOverviewRow.vue | 12 +- .../logs/components/LogsPanelActions.vue | 6 +- .../ExternalSecretsProviderCard.ee.vue | 2 +- .../ExternalSecretsProviderModal.ee.vue | 7 +- .../EventDestinationSettingsModal.vue | 4 +- .../SecretsProvidersEmptyState.ee.vue | 7 +- .../components/SourceControlPullModal.vue | 6 +- .../components/SourceControlPushModal.vue | 6 +- .../views/SettingsSourceControl.vue | 8 +- .../ndv/panel/components/NDVHeader.vue | 3 +- .../panel/components/NDVSubConnections.vue | 2 +- .../__snapshots__/InputPanel.test.ts.snap | 50 +- .../NDVSubConnections.test.ts.snap | 2 +- .../AssignmentCollection/Assignment.vue | 6 +- .../ButtonParameter/ButtonParameter.vue | 2 +- .../Collection/CollectionParameterLegacy.vue | 8 +- .../Collection/CollectionParameterNew.vue | 14 +- .../components/ExpressionParameterInput.vue | 7 +- .../components/FilterConditions/Condition.vue | 6 +- .../FilterConditions/FilterConditions.vue | 3 +- .../FixedCollection/FixedCollectionItem.vue | 7 +- .../FixedCollectionParameterLegacy.vue | 13 +- .../FixedCollectionParameterNew.vue | 16 +- .../components/ImportCurlParameter.vue | 4 +- .../components/MultipleParameter.vue | 4 +- .../components/ParameterInputList.vue | 6 +- .../FromAiOverrideButton.vue | 2 +- .../FromAiOverrideField.vue | 2 +- .../components/ParameterOptions.vue | 6 +- .../ResourceMapper/MappingFields.vue | 6 +- .../ResourceMapper/ResourceMapper.vue | 4 +- .../MultipleParameter.test.ts.snap | 9 +- .../ndv/runData/components/RunData.vue | 16 +- .../ndv/runData/components/RunDataBinary.vue | 2 +- .../runData/components/RunDataJsonActions.vue | 4 +- .../components/RunDataParsedAiContent.vue | 2 +- .../runData/components/RunDataPinButton.vue | 2 +- .../ndv/runData/components/RunDataTable.vue | 15 +- .../components/ai/AiRunContentBlock.vue | 2 +- .../components/error/NodeErrorView.vue | 11 +- .../NodeSettingsInvalidNodeWarning.vue | 4 +- .../project-roles/ProjectRoleView.test.ts | 90 +-- .../project-roles/ProjectRoleView.vue | 17 +- .../project-roles/ProjectRolesView.vue | 2 +- .../src/features/resolvers/ResolversView.vue | 11 +- .../components/CommunityPackageCard.vue | 2 +- .../CommunityPackageManageConfirmModal.vue | 2 +- .../nodeCreator/CommunityNodeUpdateInfo.vue | 2 +- .../components/VariableModal.vue | 2 +- .../components/VariablesForm.vue | 4 +- .../migrationReport/MigrationRuleDetail.vue | 3 +- .../migrationReport/MigrationRules.vue | 2 +- .../settings/sso/components/SSOLogin.vue | 3 +- .../sso/components/SamlSettingsForm.vue | 2 +- .../components/ConfirmProvisioningDialog.vue | 16 +- .../settings/sso/views/SettingsLdapView.vue | 2 +- .../CommunityPlusEnrollmentModal.vue | 13 +- .../usage/components/EulaAcceptanceModal.vue | 4 +- .../usage/views/SettingsUsageAndPlan.vue | 6 +- .../users/components/InviteUsersModal.vue | 4 +- .../components/NodeSetupCard.test.ts | 2 +- .../banners/EmailConfirmationBanner.vue | 3 +- .../components/banners/TrialBanner.vue | 6 +- .../components/banners/TrialOverBanner.vue | 11 +- .../OutputItemSelect.vue | 10 +- .../ItemTypes/CommunityNodeItem.vue | 2 +- .../nodeCreator/components/NodeCreator.vue | 2 +- .../shared/nodeCreator/views/NodeCreation.vue | 12 +- .../TagsManager/TagsView/TagsTable.vue | 6 +- .../elements/buttons/CanvasChatButton.vue | 4 +- .../elements/buttons/CanvasControlButtons.vue | 20 +- .../buttons/CanvasRunWorkflowButton.vue | 4 +- .../CanvasStopCurrentExecutionButton.vue | 2 +- .../CanvasStopWaitingForWebhookButton.vue | 2 +- .../CanvasChatButton.test.ts.snap | 2 +- ...anvasClearExecutionDataButton.test.ts.snap | 2 +- .../CanvasControlButtons.test.ts.snap | 8 +- .../CanvasRunWorkflowButton.test.ts.snap | 2 +- ...vasStopCurrentExecutionButton.test.ts.snap | 2 +- ...asStopWaitingForWebhookButton.test.ts.snap | 2 +- .../elements/edges/CanvasEdgeToolbar.vue | 4 +- .../elements/nodes/CanvasNodeToolbar.vue | 15 +- .../render-types/parts/CanvasNodeTrigger.vue | 6 +- .../ExperimentalEmbeddedNdvActions.vue | 10 +- .../ExperimentalFocusPanelHeader.vue | 12 +- .../ExperimentalNodeDetailsDrawer.vue | 2 +- .../components/ReadyToRunButton.vue | 2 +- .../SetupWorkflowCredentialsButton.vue | 2 +- .../templates/components/TemplateCard.vue | 2 +- .../templates/views/TemplatesSearchView.vue | 3 +- .../workflowDiff/WorkflowDiffAside.vue | 3 +- .../workflowDiff/WorkflowDiffModal.vue | 8 +- .../workflowDiff/WorkflowDiffView.vue | 6 +- .../components/WorkflowHistoryButton.vue | 3 +- .../components/WorkflowHistoryContent.vue | 2 +- .../WorkflowHistoryVersionRestoreModal.vue | 6 +- .../WorkflowHistoryVersionUnpublishModal.vue | 4 +- .../components/WorkflowVersionFormModal.vue | 2 +- .../workflowHistory/views/WorkflowHistory.vue | 2 +- 232 files changed, 2202 insertions(+), 2750 deletions(-) create mode 100755 packages/frontend/@n8n/design-system/scripts/migrate-button-v2.mjs create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nButton/Button.legacy.scss delete mode 100644 packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss delete mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Button/Button.stories.ts delete mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Button/Button.test.ts delete mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Button/Button.vue delete mode 100644 packages/frontend/@n8n/design-system/src/v2/components/Button/component-button.md 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