Skip to content

Commit 091d983

Browse files
authored
Merge pull request #277 from htmlhint/272-tag-no-obsolete---autofix-accronym-to-abbr
feat: add `tag-no-obsolete` autofix
2 parents f6bd62b + f66f4f5 commit 091d983

File tree

4 files changed

+230
-0
lines changed

4 files changed

+230
-0
lines changed

htmlhint-server/src/server.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,226 @@ function createSpecCharEscapeFix(
15661566
};
15671567
}
15681568

1569+
/**
1570+
* Create auto-fix action for tag-no-obsolete rule
1571+
*
1572+
* This fixes obsolete HTML tags by converting them to their modern equivalents.
1573+
* Currently supports converting <acronym> tags to <abbr> tags.
1574+
*
1575+
* Example:
1576+
* - Before: <acronym title="HyperText Markup Language">HTML</acronym>
1577+
* - After: <abbr title="HyperText Markup Language">HTML</abbr>
1578+
*/
1579+
function createTagNoObsoleteFix(
1580+
document: TextDocument,
1581+
diagnostic: Diagnostic,
1582+
): CodeAction | null {
1583+
trace(
1584+
`[DEBUG] createTagNoObsoleteFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
1585+
);
1586+
1587+
if (!diagnostic.data || diagnostic.data.ruleId !== "tag-no-obsolete") {
1588+
trace(`[DEBUG] createTagNoObsoleteFix: Invalid diagnostic data or ruleId`);
1589+
return null;
1590+
}
1591+
1592+
const text = document.getText();
1593+
const edits: TextEdit[] = [];
1594+
1595+
// Find all acronym tags in the entire document
1596+
const acronymPattern = /<\/?(acronym|ACRONYM)((?:\s+[^>]*?)?)\s*(\/?)>/gi;
1597+
let match;
1598+
const acronymTags: Array<{
1599+
startOffset: number;
1600+
endOffset: number;
1601+
tagName: string;
1602+
attributes: string;
1603+
selfClosing: string;
1604+
isClosingTag: boolean;
1605+
fullMatch: string;
1606+
line: number;
1607+
column: number;
1608+
}> = [];
1609+
1610+
// Collect all acronym tags with their positions
1611+
while ((match = acronymPattern.exec(text)) !== null) {
1612+
const startOffset = match.index;
1613+
const endOffset = startOffset + match[0].length;
1614+
const tagName = match[1].toLowerCase();
1615+
const attributes = match[2] || "";
1616+
const selfClosing = match[3] || "";
1617+
const isClosingTag = match[0].startsWith("</");
1618+
1619+
// Only handle acronym tags for now
1620+
if (tagName === "acronym") {
1621+
const position = document.positionAt(startOffset);
1622+
acronymTags.push({
1623+
startOffset,
1624+
endOffset,
1625+
tagName,
1626+
attributes,
1627+
selfClosing,
1628+
isClosingTag,
1629+
fullMatch: match[0],
1630+
line: position.line,
1631+
column: position.character,
1632+
});
1633+
}
1634+
}
1635+
1636+
// Find the diagnostic position
1637+
const diagnosticLine = diagnostic.data.line - 1;
1638+
const diagnosticCol = diagnostic.data.col - 1;
1639+
1640+
// Find the closest acronym tag to the diagnostic position
1641+
let closestTag: (typeof acronymTags)[0] | null = null;
1642+
let minDistance = Infinity;
1643+
1644+
for (const tag of acronymTags) {
1645+
const distance =
1646+
Math.abs(tag.line - diagnosticLine) +
1647+
Math.abs(tag.column - diagnosticCol);
1648+
if (distance < minDistance) {
1649+
minDistance = distance;
1650+
closestTag = tag;
1651+
}
1652+
}
1653+
1654+
if (!closestTag || minDistance > 20) {
1655+
trace(`[DEBUG] createTagNoObsoleteFix: No close acronym tag found`);
1656+
return null;
1657+
}
1658+
1659+
// If this is a self-closing tag, just convert it
1660+
if (closestTag.selfClosing === "/") {
1661+
const startPos = document.positionAt(closestTag.startOffset);
1662+
const endPos = document.positionAt(closestTag.endOffset);
1663+
1664+
edits.push({
1665+
range: { start: startPos, end: endPos },
1666+
newText: `<abbr${closestTag.attributes} />`,
1667+
});
1668+
} else if (closestTag.isClosingTag) {
1669+
// If this is a closing tag, find its opening tag
1670+
const openingTag = findOpeningTag(acronymTags, closestTag);
1671+
if (openingTag) {
1672+
// Convert both opening and closing tags
1673+
const openingStartPos = document.positionAt(openingTag.startOffset);
1674+
const openingEndPos = document.positionAt(openingTag.endOffset);
1675+
const closingStartPos = document.positionAt(closestTag.startOffset);
1676+
const closingEndPos = document.positionAt(closestTag.endOffset);
1677+
1678+
edits.push({
1679+
range: { start: openingStartPos, end: openingEndPos },
1680+
newText: `<abbr${openingTag.attributes}>`,
1681+
});
1682+
edits.push({
1683+
range: { start: closingStartPos, end: closingEndPos },
1684+
newText: "</abbr>",
1685+
});
1686+
}
1687+
} else {
1688+
// If this is an opening tag, find its closing tag
1689+
const closingTag = findClosingTag(acronymTags, closestTag);
1690+
if (closingTag) {
1691+
// Convert both opening and closing tags
1692+
const openingStartPos = document.positionAt(closestTag.startOffset);
1693+
const openingEndPos = document.positionAt(closestTag.endOffset);
1694+
const closingStartPos = document.positionAt(closingTag.startOffset);
1695+
const closingEndPos = document.positionAt(closingTag.endOffset);
1696+
1697+
edits.push({
1698+
range: { start: openingStartPos, end: openingEndPos },
1699+
newText: `<abbr${closestTag.attributes}>`,
1700+
});
1701+
edits.push({
1702+
range: { start: closingStartPos, end: closingEndPos },
1703+
newText: "</abbr>",
1704+
});
1705+
}
1706+
}
1707+
1708+
if (edits.length === 0) {
1709+
trace(`[DEBUG] createTagNoObsoleteFix: No edits created`);
1710+
return null;
1711+
}
1712+
1713+
const workspaceEdit: WorkspaceEdit = {
1714+
changes: {
1715+
[document.uri]: edits,
1716+
},
1717+
};
1718+
1719+
return {
1720+
title: "Convert obsolete tag to modern equivalent",
1721+
kind: CodeActionKind.QuickFix,
1722+
edit: workspaceEdit,
1723+
isPreferred: true,
1724+
};
1725+
}
1726+
1727+
/**
1728+
* Helper function to find the opening tag for a given closing tag
1729+
*/
1730+
function findOpeningTag(
1731+
tags: Array<{
1732+
startOffset: number;
1733+
endOffset: number;
1734+
tagName: string;
1735+
attributes: string;
1736+
selfClosing: string;
1737+
isClosingTag: boolean;
1738+
fullMatch: string;
1739+
line: number;
1740+
column: number;
1741+
}>,
1742+
closingTag: (typeof tags)[0],
1743+
): (typeof tags)[0] | null {
1744+
// Find the most recent opening tag before this closing tag
1745+
let openingTag: (typeof tags)[0] | null = null;
1746+
1747+
for (const tag of tags) {
1748+
if (!tag.isClosingTag && tag.startOffset < closingTag.startOffset) {
1749+
if (!openingTag || tag.startOffset > openingTag.startOffset) {
1750+
openingTag = tag;
1751+
}
1752+
}
1753+
}
1754+
1755+
return openingTag;
1756+
}
1757+
1758+
/**
1759+
* Helper function to find the closing tag for a given opening tag
1760+
*/
1761+
function findClosingTag(
1762+
tags: Array<{
1763+
startOffset: number;
1764+
endOffset: number;
1765+
tagName: string;
1766+
attributes: string;
1767+
selfClosing: string;
1768+
isClosingTag: boolean;
1769+
fullMatch: string;
1770+
line: number;
1771+
column: number;
1772+
}>,
1773+
openingTag: (typeof tags)[0],
1774+
): (typeof tags)[0] | null {
1775+
// Find the first closing tag after this opening tag
1776+
let closingTag: (typeof tags)[0] | null = null;
1777+
1778+
for (const tag of tags) {
1779+
if (tag.isClosingTag && tag.startOffset > openingTag.startOffset) {
1780+
if (!closingTag || tag.startOffset < closingTag.startOffset) {
1781+
closingTag = tag;
1782+
}
1783+
}
1784+
}
1785+
1786+
return closingTag;
1787+
}
1788+
15691789
/**
15701790
* Create auto-fix actions for supported rules
15711791
*/
@@ -1656,6 +1876,10 @@ async function createAutoFixes(
16561876
trace(`[DEBUG] Calling createTagSelfCloseFix`);
16571877
fix = createTagSelfCloseFix(document, diagnostic);
16581878
break;
1879+
case "tag-no-obsolete":
1880+
trace(`[DEBUG] Calling createTagNoObsoleteFix`);
1881+
fix = createTagNoObsoleteFix(document, diagnostic);
1882+
break;
16591883
default:
16601884
trace(`[DEBUG] No autofix function found for rule: ${ruleId}`);
16611885
break;

htmlhint/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

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

5+
### v1.11.1 (2025-06-23)
6+
7+
- Add autofix for `tag-no-obsolete` rule
8+
59
### v1.11.0 (2025-06-20)
610

711
- Option to skip linting files ignored by `.gitignore` (`htmlhint.ignoreGitignore`).

htmlhint/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
3939
- **`meta-description-require`** - Adds description meta tag
4040
- **`meta-viewport-require`** - Adds viewport meta tag
4141
- **`spec-char-escape`** - Escapes special characters (`<`, `>`)
42+
- **`tag-no-obsolete`** - Converts obsolete tags to modern equivalents (e.g., `<acronym>` to `<abbr>`)
4243
- **`tag-self-close`** - Converts self-closable tags to self-closing tags
4344
- **`tagname-lowercase`** - Converts uppercase tag names to lowercase
4445
- **`title-require`** - Adds `<title>` tag to document

test/autofix/.htmlhintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"meta-viewport-require": true,
1616
"spec-char-escape": true,
1717
"src-not-empty": true,
18+
"tag-no-obsolete": true,
1819
"tag-pair": true,
1920
"tag-self-close": true,
2021
"tagname-lowercase": true,

0 commit comments

Comments
 (0)