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
94 changes: 94 additions & 0 deletions htmlhint-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,96 @@ function createMetaDescriptionRequireFix(
};
}

/**
* Create auto-fix action for link-rel-canonical-require rule
*/
function createLinkRelCanonicalRequireFix(
document: TextDocument,
diagnostic: Diagnostic,
): CodeAction | null {
if (
!diagnostic.data ||
diagnostic.data.ruleId !== "link-rel-canonical-require"
) {
return null;
}

const text = document.getText();
const headMatch = text.match(/<head(\s[^>]*)?>([\s\S]*?)<\/head>/i);

if (!headMatch) {
return null;
}

const headContent = headMatch[2];
const canonicalMatch = headContent.match(
/<link\s+[^>]*rel\s*=\s*["']canonical["'][^>]*>/i,
);

if (canonicalMatch) {
return null; // Canonical link tag already exists
}

// Find a good position to insert canonical link tag (after meta tags if they exist, otherwise at the beginning of head)
const headStart = headMatch.index! + headMatch[0].indexOf(">") + 1;
const metaCharsetMatch = headContent.match(
/<meta\s+charset\s*=\s*["'][^"']*["'][^>]*>/i,
);
const metaViewportMatch = headContent.match(
/<meta\s+name\s*=\s*["']viewport["'][^>]*>/i,
);
const metaDescriptionMatch = headContent.match(
/<meta\s+name\s*=\s*["']description["'][^>]*>/i,
);

let insertPosition: number;
const shouldSelfClose = isRuleEnabledForDocument(document, "tag-self-close");
const canonicalSnippet =
'\n <link rel="canonical" href=""' +
(shouldSelfClose ? " />" : ">");

if (metaDescriptionMatch) {
// Insert after description meta tag
const metaDescriptionEnd =
headStart + metaDescriptionMatch.index! + metaDescriptionMatch[0].length;
insertPosition = metaDescriptionEnd;
} else if (metaViewportMatch) {
// Insert after viewport meta tag
const metaViewportEnd =
headStart + metaViewportMatch.index! + metaViewportMatch[0].length;
insertPosition = metaViewportEnd;
} else if (metaCharsetMatch) {
// Insert after charset meta tag
const metaCharsetEnd =
headStart + metaCharsetMatch.index! + metaCharsetMatch[0].length;
insertPosition = metaCharsetEnd;
} else {
// Insert at the beginning of head
insertPosition = headStart;
}

const edit: TextEdit = {
range: {
start: document.positionAt(insertPosition),
end: document.positionAt(insertPosition),
},
newText: canonicalSnippet,
};

const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: [edit],
},
};

return {
title: 'Add <link rel="canonical"> tag',
kind: CodeActionKind.QuickFix,
edit: workspaceEdit,
isPreferred: true,
};
}

/**
* Create auto-fix action for alt-require rule
*/
Expand Down Expand Up @@ -2432,6 +2522,10 @@ async function createAutoFixes(
trace(`[DEBUG] Calling createMetaDescriptionRequireFix`);
fix = await createMetaDescriptionRequireFix(document, diagnostic);
break;
case "link-rel-canonical-require":
trace(`[DEBUG] Calling createLinkRelCanonicalRequireFix`);
fix = await createLinkRelCanonicalRequireFix(document, diagnostic);
break;
case "alt-require":
trace(`[DEBUG] Calling createAltRequireFix`);
fix = createAltRequireFix(document, diagnostic);
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.15.0 (2025-11-27)
### v1.15.0 (TBD)

- Add autofix for the `empty-tag-not-self-closed` rule
- Add autofix for the `link-rel-canonical-require` 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 @@ -39,6 +39,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
- **`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
- **`link-rel-canonical-require`** - Adds canonical link tag
- **`meta-charset-require`** - Adds charset meta tag
- **`meta-description-require`** - Adds description meta tag
- **`meta-viewport-require`** - Adds viewport meta tag
Expand Down
3 changes: 2 additions & 1 deletion test/autofix/.htmlhintrc
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
"button-type-require": true,
"doctype-first": true,
"doctype-html5": true,
"empty-tag-not-self-closed": true,
"empty-tag-not-self-closed": false,
"html-lang-require": true,
"id-unique": true,
"link-rel-canonical-require": true,
"meta-charset-require": true,
"meta-description-require": true,
"meta-viewport-require": true,
Expand Down
12 changes: 12 additions & 0 deletions test/autofix/link-canonical-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!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="Link Canonical Test" />
<title>Link Canonical Test</title>
</head>
<body>
<h1>Link Canonical Test</h1>
</body>
</html>