diff --git a/htmlhint-server/src/server.ts b/htmlhint-server/src/server.ts index f904fe3..2646103 100644 --- a/htmlhint-server/src/server.ts +++ b/htmlhint-server/src/server.ts @@ -2230,6 +2230,142 @@ function createFormMethodRequireFix( }; } +/** + * Create auto-fix action for empty-tag-not-self-closed rule + * + * This fixes void HTML elements by converting them to proper self-closing format. + * Void elements should be self-closing and never have closing tags. + * Only applies to void HTML elements (br, img, hr, input, etc.). + * + * Example transformations: + * -

+ * - + * -

+ */ +function createEmptyTagNotSelfClosedFix( + document: TextDocument, + diagnostic: Diagnostic, +): CodeAction | null { + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix called with diagnostic: ${JSON.stringify(diagnostic)}`, + ); + + if ( + !diagnostic.data || + diagnostic.data.ruleId !== "empty-tag-not-self-closed" + ) { + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix: Invalid diagnostic data or ruleId: ${JSON.stringify(diagnostic.data)}`, + ); + return null; + } + + const text = document.getText(); + const diagnosticOffset = document.offsetAt(diagnostic.range.start); + + // List of void elements that should be self-closing + const voidElements = [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "track", + "wbr", + ]; + + // Find the tag boundaries around the diagnostic position + const tagBoundaries = findTagBoundaries(text, diagnosticOffset); + if (!tagBoundaries) { + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix: Could not find tag boundaries`, + ); + return null; + } + + const { tagStart, tagEnd } = tagBoundaries; + + // Extract the content around the diagnostic position to find void elements + const searchStart = Math.max(0, tagStart - 50); + const searchEnd = Math.min(text.length, tagEnd + 50); + const searchText = text.substring(searchStart, searchEnd); + + // Pattern to match void elements that are not self-closing: or + const voidElementPattern = /<(\w+)(\s[^>]*?)?\s*>/gi; + let match; + + while ((match = voidElementPattern.exec(searchText)) !== null) { + const matchStart = searchStart + match.index; + const matchEnd = matchStart + match[0].length; + const tagName = match[1].toLowerCase(); + const attributes = match[2] || ""; + const fullMatch = match[0]; + + // Skip if already self-closing + if (fullMatch.endsWith("/>")) { + continue; + } + + // Check if this match contains our diagnostic position + if (matchStart <= diagnosticOffset && diagnosticOffset <= matchEnd) { + // Verify this is a void element + if (!voidElements.includes(tagName)) { + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix: ${tagName} is not a void element`, + ); + return null; + } + + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix: Found non-self-closing ${tagName} tag at position ${matchStart}-${matchEnd}`, + ); + + // Create the self-closing replacement + const selfClosingTag = attributes.trim() + ? `<${tagName}${attributes} />` + : `<${tagName} />`; + + const edit: TextEdit = { + range: { + start: document.positionAt(matchStart), + end: document.positionAt(matchEnd), + }, + newText: selfClosingTag, + }; + + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix: Will replace "${fullMatch}" with "${selfClosingTag}"`, + ); + + const action = CodeAction.create( + `Convert ${tagName} tag to self-closing`, + { + changes: { + [document.uri]: [edit], + }, + }, + CodeActionKind.QuickFix, + ); + + action.diagnostics = [diagnostic]; + action.isPreferred = true; + + return action; + } + } + + trace( + `[DEBUG] createEmptyTagNotSelfClosedFix: No matching empty tag pair found`, + ); + return null; +} + /** * Create auto-fix actions for supported rules */ @@ -2336,6 +2472,10 @@ async function createAutoFixes( trace(`[DEBUG] Calling createFormMethodRequireFix`); fix = createFormMethodRequireFix(document, diagnostic); break; + case "empty-tag-not-self-closed": + trace(`[DEBUG] Calling createEmptyTagNotSelfClosedFix`); + fix = createEmptyTagNotSelfClosedFix(document, diagnostic); + break; default: trace(`[DEBUG] No autofix function found for rule: ${ruleId}`); break; diff --git a/htmlhint/CHANGELOG.md b/htmlhint/CHANGELOG.md index fe81b23..23cb610 100644 --- a/htmlhint/CHANGELOG.md +++ b/htmlhint/CHANGELOG.md @@ -4,7 +4,8 @@ All notable changes to the "vscode-htmlhint" extension will be documented in thi ### v1.15.0 (2025-11-27) -- Smarter autofix for rules which accommodates for `tag-self-close` rule. +- Add autofix for the `empty-tag-not-self-closed` rule +- Smarter autofix for rules which accommodates for `tag-self-close` rule ### v1.14.0 (2025-11-26) diff --git a/htmlhint/README.md b/htmlhint/README.md index 6975576..aec2be9 100644 --- a/htmlhint/README.md +++ b/htmlhint/README.md @@ -36,6 +36,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su - **`button-type-require`** - Adds type attribute to buttons - **`doctype-first`** - Adds DOCTYPE declaration at the beginning - **`doctype-html5`** - Updates DOCTYPE to HTML5 +- **`empty-tag-not-self-closed`** - Converts void elements to self-closing format (e.g., `
` → `
`) - **`form-method-require`** - Adds empty method attribute to forms - **`html-lang-require`** - Adds `lang` attribute to `` tag - **`meta-charset-require`** - Adds charset meta tag diff --git a/test/autofix/.htmlhintrc b/test/autofix/.htmlhintrc index e2b0c0e..f088227 100644 --- a/test/autofix/.htmlhintrc +++ b/test/autofix/.htmlhintrc @@ -9,6 +9,7 @@ "button-type-require": true, "doctype-first": true, "doctype-html5": true, + "empty-tag-not-self-closed": true, "html-lang-require": true, "id-unique": true, "meta-charset-require": true, diff --git a/test/autofix/empty-tag-not-self-closed-test.html b/test/autofix/empty-tag-not-self-closed-test.html new file mode 100644 index 0000000..3b3aec0 --- /dev/null +++ b/test/autofix/empty-tag-not-self-closed-test.html @@ -0,0 +1,68 @@ + + + + + + + Empty Tag Not Self-Closed Test + + + +
+ + + Test + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ Test 3 + + +
+ Test 2 +
+ + +
Content
+

Paragraph content

+ + +
+

+ +