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
63 changes: 58 additions & 5 deletions dist/core/rules/tagname-lowercase.js

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

82 changes: 78 additions & 4 deletions src/core/rules/tagname-lowercase.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,91 @@
import { Rule } from '../types'

const svgTagNameIgnores = [
'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',
'linearGradient',
'radialGradient',
'textPath',
]

/**
* testAgainstStringOrRegExp
*
* @param value string to test
* @param comparison raw string or regex string
*/
function testAgainstStringOrRegExp(value: string, comparison: string | RegExp) {
// If it's a RegExp, test directly
if (comparison instanceof RegExp) {
return comparison.test(value)
? { match: value, pattern: comparison }
: false
}

// Check if it's RegExp in a string
const firstComparisonChar = comparison[0]
const lastComparisonChar = comparison[comparison.length - 1]
const secondToLastComparisonChar = comparison[comparison.length - 2]

const comparisonIsRegex =
firstComparisonChar === '/' &&
(lastComparisonChar === '/' ||
(secondToLastComparisonChar === '/' && lastComparisonChar === 'i'))

const hasCaseInsensitiveFlag = comparisonIsRegex && lastComparisonChar === 'i'

// If so, create a new RegExp from it
if (comparisonIsRegex) {
const valueMatches = hasCaseInsensitiveFlag
? new RegExp(comparison.slice(1, -2), 'i').test(value)
: new RegExp(comparison.slice(1, -1)).test(value)

return valueMatches
}

// Otherwise, it's a string. Do a strict comparison
return value === comparison
Comment on lines +39 to +74
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The testAgainstStringOrRegExp function is duplicated from attr-lowercase.ts (lines 77-114). This creates code duplication and increases maintenance burden. Consider extracting this function to a shared utility module to avoid duplication and ensure consistency across both rules.

Suggested change
* testAgainstStringOrRegExp
*
* @param value string to test
* @param comparison raw string or regex string
*/
function testAgainstStringOrRegExp(value: string, comparison: string | RegExp) {
// If it's a RegExp, test directly
if (comparison instanceof RegExp) {
return comparison.test(value)
? { match: value, pattern: comparison }
: false
}
// Check if it's RegExp in a string
const firstComparisonChar = comparison[0]
const lastComparisonChar = comparison[comparison.length - 1]
const secondToLastComparisonChar = comparison[comparison.length - 2]
const comparisonIsRegex =
firstComparisonChar === '/' &&
(lastComparisonChar === '/' ||
(secondToLastComparisonChar === '/' && lastComparisonChar === 'i'))
const hasCaseInsensitiveFlag = comparisonIsRegex && lastComparisonChar === 'i'
// If so, create a new RegExp from it
if (comparisonIsRegex) {
const valueMatches = hasCaseInsensitiveFlag
? new RegExp(comparison.slice(1, -2), 'i').test(value)
: new RegExp(comparison.slice(1, -1)).test(value)
return valueMatches
}
// Otherwise, it's a string. Do a strict comparison
return value === comparison
* Normalize a string or RegExp into a usable matcher.
*
* Supports:
* - RegExp instances
* - Regex-like strings such as "/pattern/" or "/pattern/i"
* - Plain strings (falls back to strict equality)
*/
function normalizeStringOrRegExp(
comparison: string | RegExp
): RegExp | string {
if (comparison instanceof RegExp) {
return comparison
}
// Attempt to interpret strings like "/pattern/flags" as regular expressions.
if (comparison.length >= 2 && comparison[0] === '/') {
const lastSlashIndex = comparison.lastIndexOf('/')
if (lastSlashIndex > 0) {
const pattern = comparison.slice(1, lastSlashIndex)
const flags = comparison.slice(lastSlashIndex + 1)
// Only accept valid JS RegExp flags; otherwise, treat as a plain string.
if (/^[gimsuy]*$/.test(flags)) {
try {
return new RegExp(pattern, flags)
} catch {
// If the pattern is invalid, fall through and treat as a plain string.
}
}
}
}
return comparison
}
/**
* Test a value against a string, regex-like string, or RegExp.
*
* @param value string to test
* @param comparison raw string, regex-like string, or RegExp
*/
function testAgainstStringOrRegExp(
value: string,
comparison: string | RegExp
): boolean {
const matcher = normalizeStringOrRegExp(comparison)
if (matcher instanceof RegExp) {
return matcher.test(value)
}
return value === matcher

Copilot uses AI. Check for mistakes.
}

export default {
id: 'tagname-lowercase',
description: 'All html element names must be in lowercase.',
init(parser, reporter, options) {
const exceptions: Array<string | boolean> = Array.isArray(options)
? options
: []
const exceptions = (Array.isArray(options) ? options : []).concat(
svgTagNameIgnores
)

parser.addListener('tagstart,tagend', (event) => {
const tagName = event.tagName
if (
exceptions.indexOf(tagName) === -1 &&
!exceptions.find((exp) => testAgainstStringOrRegExp(tagName, exp)) &&
tagName !== tagName.toLowerCase()
) {
reporter.error(
Expand Down
2 changes: 1 addition & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface Ruleset {
'tag-no-obsolete'?: boolean
'tag-pair'?: boolean
'tag-self-close'?: boolean
'tagname-lowercase'?: boolean
'tagname-lowercase'?: boolean | Array<string | RegExp>
'tagname-specialchars'?: boolean
'tags-check'?: { [tagName: string]: Record<string, unknown> }
'title-require'?: boolean
Expand Down
23 changes: 23 additions & 0 deletions test/rules/tagname-lowercase.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,27 @@ describe(`Rules: ${ruleId}`, () => {
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})

it('Known SVG elements should be ignored with no config', () => {
const code =
'<svg><animateMotion /><linearGradient /><foreignObject /><textPath /></svg>'
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})

it('Known SVG elements should be ignored with a config override', () => {
const code = '<svg><feGaussianBlur /><radialGradient /></svg>'
ruleOptions[ruleId] = ['customTag']
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
ruleOptions[ruleId] = true
})

it('Set to array list should not result in an error', () => {
const code = '<CustomComponent /><AnotherCamelCase />'
ruleOptions[ruleId] = ['CustomComponent', 'AnotherCamelCase']
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
ruleOptions[ruleId] = true
})
Comment on lines +48 to +54
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage for RegExp support is incomplete. While the implementation supports RegExp patterns (both as RegExp objects and as string patterns like '/pattern/'), there are no tests for this functionality. The attr-lowercase rule has comprehensive RegExp tests (see test/rules/attr-lowercase.spec.js lines 48-60) including tests for RegExp objects and string-based regex patterns. Add similar test cases to ensure the RegExp functionality works correctly.

Copilot uses AI. Check for mistakes.
})
8 changes: 7 additions & 1 deletion website/src/content/docs/rules/tagname-lowercase.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@ Level: <Badge text="Error" variant="danger" />

- `true`: enable rule
- `false`: disable rule
- `['clipPath', 'data-Test']`: Ignore some tagname name
- `['clipPath', 'data-Test']`: enable rule except for the given tag names. All SVG camelCase elements are included, for example `linearGradient`, `foreignObject`
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation does not mention that RegExp patterns are supported in the array configuration. While the implementation supports both RegExp objects and string-based regex patterns (e.g., '/pattern/' or '/pattern/i'), this capability is not documented. Consider adding an example showing RegExp support, similar to the attr-lowercase rule documentation, to help users understand this feature.

Suggested change
- `['clipPath', 'data-Test']`: enable rule except for the given tag names. All SVG camelCase elements are included, for example `linearGradient`, `foreignObject`
- `['clipPath', 'data-Test']`: enable rule except for the given tag names. All SVG camelCase elements are included, for example `linearGradient`, `foreignObject`. The array can include literal tag names as strings, `RegExp` objects, or string-based regex patterns such as `'/^svg[A-Z]/'` or `'/^data-/i'`, for example:
- `['clipPath', /data-[A-Z]\w+/, '/^my-/i']`

Copilot uses AI. Check for mistakes.

### The following patterns are **not** considered rule violations

```html
<span><div>
```

SVG elements with camelCase names (e.g. `linearGradient`, `foreignObject`) are allowed:

```html
<svg><linearGradient /><foreignObject /><textPath /></svg>
```

### The following pattern is considered a rule violation:

```html
Expand Down
Loading