Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
171 changes: 137 additions & 34 deletions htmlhint-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2232,6 +2232,89 @@ async function createAutoFixes(
return actions;
}

/**
* Suppress a rule for the diagnostic's line by adding an
* <!-- htmlhint-disable-next-line rule-id --> comment above it. If the line above
* already has such a comment, the rule is appended to it.
*/
function createDisableNextLineAction(
document: TextDocument,
diagnostic: Diagnostic,
): CodeAction | null {
const ruleId = diagnostic.data?.ruleId;
if (typeof ruleId !== "string" || !ruleId) {
return null;
}

const lineIndex = diagnostic.range.start.line;
const text = document.getText();
const lines = text.split(/\r?\n/);
const lineText = lines[lineIndex];
if (lineText === undefined) {
return null;
}

const title = `Disable ${ruleId} for this line`;
const previousLine = lineIndex > 0 ? lines[lineIndex - 1] : undefined;
// HTMLHint only honours the first directive on a line, wherever it appears
const existing = previousLine?.match(
/<!--\s*htmlhint-(disable|enable)(-next-line)?(?:\s+([^\r\n]+?))?\s*-->/i,
);

if (
existing?.index !== undefined &&
existing[1].toLowerCase() === "disable" &&
existing[2]
) {
// Without a rule list the comment already disables every rule
if (!existing[3]) {
return null;
}

const rules = existing[3].split(/\s+/).filter((rule) => rule.length > 0);
if (rules.includes(ruleId)) {
return null;
}

// Replace only the directive so any surrounding content on the line is kept
return makeFix(
document,
title,
[
{
range: {
start: { line: lineIndex - 1, character: existing.index },
end: {
line: lineIndex - 1,
character: existing.index + existing[0].length,
},
},
newText: `<!-- htmlhint-disable-next-line ${[...rules, ruleId].join(" ")} -->`,
},
],
diagnostic,
);
}

const indent = lineText.match(/^\s*/)?.[0] ?? "";
const eol = text.includes("\r\n") ? "\r\n" : "\n";

return makeFix(
document,
title,
[
{
range: {
start: { line: lineIndex, character: 0 },
end: { line: lineIndex, character: 0 },
},
newText: `${indent}<!-- htmlhint-disable-next-line ${ruleId} -->${eol}`,
},
],
diagnostic,
);
}

connection.onInitialize(
(_params: InitializeParams, _token: CancellationToken) => {
// Since Files API is no longer available, we'll use embedded htmlhint directly
Expand Down Expand Up @@ -2549,7 +2632,9 @@ connection.onRequest(
}

// Create auto-fixes for each diagnostic
const codeActions: LSPCodeAction[] = [];
const fixActions: CodeAction[] = [];
const disableActions: CodeAction[] = [];
const disabledRuleLines = new Set<string>();
for (const diagnostic of filteredDiagnostics) {
trace(
`[DEBUG] Creating fixes for diagnostic: ${JSON.stringify(diagnostic)}`,
Expand Down Expand Up @@ -2581,41 +2666,59 @@ connection.onRequest(
trace(
`[DEBUG] Created ${fixes.length} fixes for diagnostic: ${JSON.stringify(enhancedDiagnostic)}`,
);
codeActions.push(
...fixes.map((fix) => ({
title: fix.title,
kind: fix.kind,
diagnostics: fix.diagnostics,
isPreferred: fix.isPreferred,
edit: fix.edit
? {
changes: fix.edit.documentChanges
? {
[uri]: (fix.edit.documentChanges[0] as any).edits || [],
}
: fix.edit.changes && fix.edit.changes[uri]
? {
[uri]: fix.edit.changes[uri].map((change) => ({
range: {
start: {
line: change.range.start.line,
character: change.range.start.character,
},
end: {
line: change.range.end.line,
character: change.range.end.character,
},
},
newText: change.newText,
})),
}
: { [uri]: [] },
}
: undefined,
})),
);

// Offer one disable action per rule and line, listed after all autofixes
const disableKey = `${enhancedDiagnostic.range.start.line}:${enhancedDiagnostic.data?.ruleId}`;
if (!disabledRuleLines.has(disableKey)) {
disabledRuleLines.add(disableKey);
const disableAction = createDisableNextLineAction(
document,
enhancedDiagnostic,
);
if (disableAction) {
disableAction.isPreferred = false;
disableActions.push(disableAction);
}
}

fixActions.push(...fixes);
}

const codeActions: LSPCodeAction[] = [
...fixActions,
...disableActions,
].map((fix) => ({
title: fix.title,
kind: fix.kind,
diagnostics: fix.diagnostics,
isPreferred: fix.isPreferred,
edit: fix.edit
? {
changes: fix.edit.documentChanges
? {
[uri]: (fix.edit.documentChanges[0] as any).edits || [],
}
: fix.edit.changes && fix.edit.changes[uri]
? {
[uri]: fix.edit.changes[uri].map((change) => ({
range: {
start: {
line: change.range.start.line,
character: change.range.start.character,
},
end: {
line: change.range.end.line,
character: change.range.end.character,
},
},
newText: change.newText,
})),
}
: { [uri]: [] },
}
: undefined,
}));

trace(`[DEBUG] Created ${codeActions.length} auto-fix actions`);
trace(`[DEBUG] Code actions: ${JSON.stringify(codeActions)}`);

Expand Down
3 changes: 2 additions & 1 deletion htmlhint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

All notable changes to the "vscode-htmlhint" extension will be documented in this file.

### v1.18.0 (2026-09-26)
### v1.18.0 (Unreleased)

- Add autofix for the `attr-unsafe-chars`, `attr-value-single-quotes` and `frame-title-require` rules
- Add "Disable rule for this line" Quick Fix for every rule, which inserts an `<!-- htmlhint-disable-next-line rule-id -->` comment

### v1.17.0 (2026-09-23)

Expand Down
2 changes: 1 addition & 1 deletion htmlhint/LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
The MIT License (MIT)

Copyright (c) 2015-2022 Microsoft
Copyright (c) 2022-2025 HTMLHint
Copyright (c) 2022-2026 HTMLHint

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Expand Down
2 changes: 2 additions & 0 deletions htmlhint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Errors in HTML files are highlighted with squiggles and you can hover over the s

Many problems can now be fixed automatically by clicking on the lightbulb icon in problems panel or right-clicking on the error in the HTML file and selecting "Quick Fix".

Every problem also offers a "Disable rule-id for this line" Quick Fix, which adds an `<!-- htmlhint-disable-next-line rule-id -->` comment above the line (or appends the rule to an existing one).

![hover](https://github.com/htmlhint/vscode-htmlhint/raw/main/htmlhint/images/hover.png)

### Auto-fix Support
Expand Down
8 changes: 4 additions & 4 deletions htmlhint/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion htmlhint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"package": "vsce package"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@types/node": "^22.20.5",
"@types/vscode": "^1.101.0",
"@vscode/test-electron": "^3.1.0",
"esbuild": "^0.28.2",
Expand Down
3 changes: 3 additions & 0 deletions test/autofix/.htmlhintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
"attr-lowercase": true,
"attr-no-duplication": true,
"attr-no-unnecessary-whitespace": true,
"attr-unsafe-chars": true,
"attr-value-double-quotes": true,
"attr-value-no-duplication": true,
"attr-value-single-quotes": false,
"attr-whitespace": true,
"button-type-require": true,
"doctype-first": true,
"doctype-html5": true,
"empty-tag-not-self-closed": false,
"frame-title-require": true,
"html-lang-require": true,
"id-unique": true,
"link-rel-canonical-require": true,
Expand Down
11 changes: 11 additions & 0 deletions test/autofix/test-autofixes.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@

<!-- Test case 5: Mixed case acronym -->
<ACRONYM title="Extensible Markup Language">XML</ACRONYM>
<!-- attr-unsafe-chars: invisible characters in attribute values (U+200E, U+00AD) -->
<a href="https://example.com/‎page">Unsafe link</a>
<div title="Soft­hyphen">Soft hyphen in title</div>

<!-- attr-value-single-quotes: double-quoted and unquoted values (enable rule in .htmlhintrc) -->
<span class="single-quotes" data-value=unquoted>Quotes</span>

<!-- frame-title-require: iframe and frame missing aria-label -->
<iframe src="https://example.com"></iframe>
<iframe src="https://example.com" aria-label="">Empty aria-label should not get a duplicate</iframe>
<frame src="frame.html">
<br />
</body>
</html>
Loading