Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions htmlhint-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* - <br> → <br/>
* - <img src="test.jpg"> → <img src="test.jpg"/>
* - <hr> → <hr/>
*/
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: <tagname> or <tagname attributes>
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
*/
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion htmlhint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions htmlhint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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., `<br>``<br/>`)
- **`form-method-require`** - Adds empty method attribute to forms
- **`html-lang-require`** - Adds `lang` attribute to `<html>` tag
- **`meta-charset-require`** - Adds charset meta tag
Expand Down
1 change: 1 addition & 0 deletions test/autofix/.htmlhintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions test/autofix/empty-tag-not-self-closed-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Empty Tag Not Self-Closed Test" />
<title>Empty Tag Not Self-Closed Test</title>
</head>
<body>
<!-- Test case 1: br tag (should be converted to self-closing) -->
<br>

<!-- Test case 2: img tag (should be converted to self-closing) -->
<img src="test.jpg" alt="Test">

<!-- Test case 3: hr tag (should be converted to self-closing) -->
<hr>

<!-- Test case 4: input tag (should be converted to self-closing) -->
<input type="text">

<!-- Test case 5: meta tag (should be converted to self-closing) -->
<meta name="test" content="test">

<!-- Test case 6: link tag (should be converted to self-closing) -->
<link rel="stylesheet" href="style.css">

<!-- Test case 7: area tag (should be converted to self-closing) -->
<area shape="rect" coords="0,0,100,100" href="#">

<!-- Test case 8: base tag (should be converted to self-closing) -->
<base href="https://example.com">

<!-- Test case 9: col tag (should be converted to self-closing) -->
<col span="2">

<!-- Test case 10: embed tag (should be converted to self-closing) -->
<embed src="test.swf" type="application/x-shockwave-flash">

<!-- Test case 11: source tag (should be converted to self-closing) -->
<source src="test.mp4" type="video/mp4">

<!-- Test case 12: track tag (should be converted to self-closing) -->
<track src="subtitles.vtt" kind="subtitles" srclang="en">

<!-- Test case 13: wbr tag (should be converted to self-closing) -->
<wbr>

<!-- Test case 14: Already self-closing tags - should not trigger -->
<br />
<img src="test2.jpg" alt="Test 2" />
<hr />

<!-- Test case 15: Non-void elements with content - should not trigger -->
<div>Content</div>
<p>Paragraph content</p>

<!-- Test case 16: Empty non-void elements - should not trigger -->
<div></div>
<p></p>
</body>
</html>