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
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-duplicate-attributes`](./html-no-duplicate-attributes.md) - Prevents duplicate attributes on HTML elements
- [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML
- [`svg-tag-name-capitalization`](./svg-tag-name-capitalization.md) - Enforces proper camelCase capitalization for SVG elements

## Contributing

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Linter Rule: SVG tag name capitalization

**Rule:** `svg-tag-name-capitalization`

## Description

Enforces proper camelCase capitalization for SVG element names within SVG contexts.

## Rationale

SVG elements use camelCase naming conventions (e.g., `linearGradient`, `clipPath`, `feGaussianBlur`) rather than the lowercase conventions used in HTML. This rule ensures that SVG elements within `<svg>` tags use the correct capitalization for proper rendering and standards compliance.

This rule only applies to elements within SVG contexts and does not check the `<svg>` tag itself (that's handled by the `html-tag-name-lowercase` rule).

## Examples

### ✅ Good

```html
<svg>
<linearGradient id="grad1">
<stop offset="0%" stop-color="rgb(255,255,0)" />
</linearGradient>
</svg>
```

```html
<svg>
<clipPath id="clip">
<rect width="100" height="100" />
</clipPath>
<feGaussianBlur stdDeviation="5" />
</svg>
```

### 🚫 Bad

```html
<svg>
<lineargradient id="grad1">
<stop offset="0%" stop-color="rgb(255,255,0)" />
</lineargradient>
</svg>
```

```html
<svg>
<CLIPPATH id="clip">
<rect width="100" height="100" />
</CLIPPATH>
</svg>
```

## References

* [SVG Element Reference](https://developer.mozilla.org/en-US/docs/Web/SVG/Element)
* [SVG Naming Conventions](https://www.w3.org/TR/SVG2/)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/default-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HTMLNoDuplicateIdsRule } from "./rules/html-no-duplicate-ids.js"
import { HTMLNoEmptyHeadingsRule } from "./rules/html-no-empty-headings.js"
import { HTMLNoNestedLinksRule } from "./rules/html-no-nested-links.js"
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"

export const defaultRules: RuleClass[] = [
ERBNoEmptyTagsRule,
Expand All @@ -36,4 +37,5 @@ export const defaultRules: RuleClass[] = [
HTMLNoEmptyHeadingsRule,
HTMLNoNestedLinksRule,
HTMLTagNameLowercaseRule,
SVGTagNameCapitalizationRule,
]
33 changes: 24 additions & 9 deletions javascript/packages/linter/src/rules/html-tag-name-lowercase.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { BaseRuleVisitor } from "./rule-utils.js"

import type { Rule, LintOffense } from "../types.js"
import type { HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"
import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"

class TagNameLowercaseVisitor extends BaseRuleVisitor {
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
this.checkTagName(node)
this.visitChildNodes(node)
}
visitHTMLElementNode(node: HTMLElementNode): void {
const tagName = node.tag_name?.value

if (node.open_tag) {
this.checkTagName(node.open_tag as HTMLOpenTagNode)
}

if (tagName && ["svg"].includes(tagName.toLowerCase())) {
if (node.close_tag) {
this.checkTagName(node.close_tag as HTMLCloseTagNode)
}

return
}

visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
this.checkTagName(node)
this.visitChildNodes(node)

if (node.close_tag) {
this.checkTagName(node.close_tag as HTMLCloseTagNode)
}
}

visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
Expand All @@ -21,17 +33,20 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {

private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | HTMLSelfCloseTagNode): void {
const tagName = node.tag_name?.value

if (!tagName) return

if (tagName !== tagName.toLowerCase()) {
const lowercaseTagName = tagName.toLowerCase()

if (tagName !== lowercaseTagName) {
let type: string = node.type

if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"

this.addOffense(
`${type} tag name \`${tagName}\` should be lowercase. Use \`${tagName.toLowerCase()}\` instead.`,
`${type} tag name \`${tagName}\` should be lowercase. Use \`${lowercaseTagName}\` instead.`,
node.tag_name!.location,
"error"
)
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from "./html-no-duplicate-ids.js"
export * from "./html-no-empty-headings.js"
export * from "./html-no-nested-links.js"
export * from "./html-tag-name-lowercase.js"
export * from "./svg-tag-name-capitalization.js"
47 changes: 47 additions & 0 deletions javascript/packages/linter/src/rules/rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,53 @@ export const HTML_BOOLEAN_ATTRIBUTES = new Set([

export const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])

/**
* SVG elements that use camelCase naming
*/
export const SVG_CAMEL_CASE_ELEMENTS = new Set([
"animateMotion",
"animateTransform",
"clipPath",
"feBlend",
"feColorMatrix",
"feComponentTransfer",
"feComposite",
"feConvolveMatrix",
"feDiffuseLighting",
"feDisplacementMap",
"feDistantLight",
"feDropShadow",
"feFlood",
"feFuncA",
"feFuncB",
"feFuncG",
"feFuncR",
"feGaussianBlur",
"feImage",
"feMerge",
"feMergeNode",
"feMorphology",
"feOffset",
"fePointLight",
"feSpecularLighting",
"feSpotLight",
"feTile",
"feTurbulence",
"foreignObject",
"glyphRef",
"linearGradient",
"radialGradient",
"textPath"
])

/**
* Mapping from lowercase SVG element names to their correct camelCase versions
* Generated dynamically from SVG_CAMEL_CASE_ELEMENTS
*/
export const SVG_LOWERCASE_TO_CAMELCASE = new Map(
Array.from(SVG_CAMEL_CASE_ELEMENTS).map(element => [element.toLowerCase(), element])
)

export const VALID_ARIA_ROLES = new Set([
"banner", "complementary", "contentinfo", "form", "main", "navigation", "region", "search",
"article", "cell", "columnheader", "definition", "directory", "document", "feed", "figure",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { BaseRuleVisitor, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE } from "./rule-utils.js"

import type { Rule, LintOffense } from "../types.js"
import type { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, Node } from "@herb-tools/core"

class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
private insideSVG = false

visitHTMLElementNode(node: HTMLElementNode): void {
const tagName = node.tag_name?.value

if (tagName && ["svg"].includes(tagName.toLowerCase())) {
const wasInsideSVG = this.insideSVG
this.insideSVG = true
this.visitChildNodes(node)
this.insideSVG = wasInsideSVG
return
}

if (this.insideSVG) {
if (node.open_tag) {
this.checkTagName(node.open_tag as HTMLOpenTagNode)
}
if (node.close_tag) {
this.checkTagName(node.close_tag as HTMLCloseTagNode)
}
}

this.visitChildNodes(node)
}

visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
if (this.insideSVG) {
this.checkTagName(node)
}
this.visitChildNodes(node)
}

private checkTagName(node: HTMLOpenTagNode | HTMLCloseTagNode | HTMLSelfCloseTagNode): void {
const tagName = node.tag_name?.value

if (!tagName) return

if (SVG_CAMEL_CASE_ELEMENTS.has(tagName)) return

const lowercaseTagName = tagName.toLowerCase()
const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName)

if (correctCamelCase && tagName !== correctCamelCase) {
let type: string = node.type

if (node.type == "AST_HTML_OPEN_TAG_NODE") type = "Opening"
if (node.type == "AST_HTML_CLOSE_TAG_NODE") type = "Closing"
if (node.type == "AST_HTML_SELF_CLOSE_TAG_NODE") type = "Self-closing"

this.addOffense(
`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`,
node.tag_name!.location,
"error"
)
}
}
}

export class SVGTagNameCapitalizationRule implements Rule {
name = "svg-tag-name-capitalization"

check(node: Node): LintOffense[] {
const visitor = new SVGTagNameCapitalizationVisitor(this.name)
visitor.visit(node)
return visitor.offenses
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,44 @@ describe("html-tag-name-lowercase", () => {
expect(lintResult.errors).toBe(0)
expect(lintResult.warnings).toBe(0)
})

test("ignores SVG child elements (handled by svg-tag-name-capitalization rule)", () => {
const html = `
<svg>
<linearGradient id="grad1">
<stop offset="0%" />
</linearGradient>
<LINEARGRADIENT id="grad2">
<stop offset="100%" />
</LINEARGRADIENT>
<lineargradient id="grad3">
<stop offset="50%" />
</lineargradient>
</svg>
`
const result = Herb.parse(html)
const linter = new Linter([HTMLTagNameLowercaseRule])
const lintResult = linter.lint(result.value)

expect(lintResult.errors).toBe(0)
expect(lintResult.warnings).toBe(0)
expect(lintResult.offenses).toHaveLength(0)
})

test("still checks SVG tag itself for lowercase", () => {
const html = `
<SVG>
<linearGradient id="grad1">
<stop offset="0%" />
</linearGradient>
</SVG>
`
const result = Herb.parse(html)
const linter = new Linter([HTMLTagNameLowercaseRule])
const lintResult = linter.lint(result.value)

expect(lintResult.errors).toBe(2) // opening and closing SVG tags
expect(lintResult.offenses[0].message).toBe('Opening tag name `SVG` should be lowercase. Use `svg` instead.')
expect(lintResult.offenses[1].message).toBe('Closing tag name `SVG` should be lowercase. Use `svg` instead.')
})
})
Loading