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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+