From 3b3d03c0081b7dbb850a004b5e487c235e7d0a06 Mon Sep 17 00:00:00 2001 From: Christian Oliff Date: Mon, 2 Feb 2026 16:09:13 +0900 Subject: [PATCH] SVG elements ignored with tagname-lowercase rule The following SVG elements are ignored: 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 --- dist/core/rules/tagname-lowercase.js | 63 ++++++++++++-- src/core/rules/tagname-lowercase.ts | 82 ++++++++++++++++++- src/core/types.ts | 2 +- test/rules/tagname-lowercase.spec.js | 23 ++++++ .../content/docs/rules/tagname-lowercase.mdx | 8 +- 5 files changed, 167 insertions(+), 11 deletions(-) diff --git a/dist/core/rules/tagname-lowercase.js b/dist/core/rules/tagname-lowercase.js index 4a9513453..e577277fe 100644 --- a/dist/core/rules/tagname-lowercase.js +++ b/dist/core/rules/tagname-lowercase.js @@ -1,19 +1,72 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +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', +]; +function testAgainstStringOrRegExp(value, comparison) { + if (comparison instanceof RegExp) { + return comparison.test(value) + ? { match: value, pattern: comparison } + : false; + } + 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 (comparisonIsRegex) { + const valueMatches = hasCaseInsensitiveFlag + ? new RegExp(comparison.slice(1, -2), 'i').test(value) + : new RegExp(comparison.slice(1, -1)).test(value); + return valueMatches; + } + return value === comparison; +} exports.default = { id: 'tagname-lowercase', description: 'All html element names must be in lowercase.', init(parser, reporter, options) { - const exceptions = 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 && + if (!exceptions.find((exp) => testAgainstStringOrRegExp(tagName, exp)) && tagName !== tagName.toLowerCase()) { reporter.error(`The html element name of [ ${tagName} ] must be in lowercase.`, event.line, event.col, this, event.raw); } }); }, }; -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGFnbmFtZS1sb3dlcmNhc2UuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29yZS9ydWxlcy90YWduYW1lLWxvd2VyY2FzZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUVBLGtCQUFlO0lBQ2IsRUFBRSxFQUFFLG1CQUFtQjtJQUN2QixXQUFXLEVBQUUsOENBQThDO0lBQzNELElBQUksQ0FBQyxNQUFNLEVBQUUsUUFBUSxFQUFFLE9BQU87UUFDNUIsTUFBTSxVQUFVLEdBQTRCLEtBQUssQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDO1lBQ2hFLENBQUMsQ0FBQyxPQUFPO1lBQ1QsQ0FBQyxDQUFDLEVBQUUsQ0FBQTtRQUVOLE1BQU0sQ0FBQyxXQUFXLENBQUMsaUJBQWlCLEVBQUUsQ0FBQyxLQUFLLEVBQUUsRUFBRTtZQUM5QyxNQUFNLE9BQU8sR0FBRyxLQUFLLENBQUMsT0FBTyxDQUFBO1lBQzdCLElBQ0UsVUFBVSxDQUFDLE9BQU8sQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLENBQUM7Z0JBQ2xDLE9BQU8sS0FBSyxPQUFPLENBQUMsV0FBVyxFQUFFLEVBQ2pDLENBQUM7Z0JBQ0QsUUFBUSxDQUFDLEtBQUssQ0FDWiw4QkFBOEIsT0FBTywwQkFBMEIsRUFDL0QsS0FBSyxDQUFDLElBQUksRUFDVixLQUFLLENBQUMsR0FBRyxFQUNULElBQUksRUFDSixLQUFLLENBQUMsR0FBRyxDQUNWLENBQUE7WUFDSCxDQUFDO1FBQ0gsQ0FBQyxDQUFDLENBQUE7SUFDSixDQUFDO0NBQ00sQ0FBQSJ9 \ No newline at end of file +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGFnbmFtZS1sb3dlcmNhc2UuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29yZS9ydWxlcy90YWduYW1lLWxvd2VyY2FzZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOztBQUVBLE1BQU0saUJBQWlCLEdBQUc7SUFDeEIsZUFBZTtJQUNmLGtCQUFrQjtJQUNsQixVQUFVO0lBQ1YsU0FBUztJQUNULGVBQWU7SUFDZixxQkFBcUI7SUFDckIsYUFBYTtJQUNiLGtCQUFrQjtJQUNsQixtQkFBbUI7SUFDbkIsbUJBQW1CO0lBQ25CLGdCQUFnQjtJQUNoQixjQUFjO0lBQ2QsU0FBUztJQUNULFNBQVM7SUFDVCxTQUFTO0lBQ1QsU0FBUztJQUNULFNBQVM7SUFDVCxnQkFBZ0I7SUFDaEIsU0FBUztJQUNULFNBQVM7SUFDVCxhQUFhO0lBQ2IsY0FBYztJQUNkLFVBQVU7SUFDVixjQUFjO0lBQ2Qsb0JBQW9CO0lBQ3BCLGFBQWE7SUFDYixRQUFRO0lBQ1IsY0FBYztJQUNkLGVBQWU7SUFDZixnQkFBZ0I7SUFDaEIsZ0JBQWdCO0lBQ2hCLFVBQVU7Q0FDWCxDQUFBO0FBUUQsU0FBUyx5QkFBeUIsQ0FBQyxLQUFhLEVBQUUsVUFBMkI7SUFFM0UsSUFBSSxVQUFVLFlBQVksTUFBTSxFQUFFLENBQUM7UUFDakMsT0FBTyxVQUFVLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQztZQUMzQixDQUFDLENBQUMsRUFBRSxLQUFLLEVBQUUsS0FBSyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUU7WUFDdkMsQ0FBQyxDQUFDLEtBQUssQ0FBQTtJQUNYLENBQUM7SUFHRCxNQUFNLG1CQUFtQixHQUFHLFVBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQTtJQUN6QyxNQUFNLGtCQUFrQixHQUFHLFVBQVUsQ0FBQyxVQUFVLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQyxDQUFBO0lBQzVELE1BQU0sMEJBQTBCLEdBQUcsVUFBVSxDQUFDLFVBQVUsQ0FBQyxNQUFNLEdBQUcsQ0FBQyxDQUFDLENBQUE7SUFFcEUsTUFBTSxpQkFBaUIsR0FDckIsbUJBQW1CLEtBQUssR0FBRztRQUMzQixDQUFDLGtCQUFrQixLQUFLLEdBQUc7WUFDekIsQ0FBQywwQkFBMEIsS0FBSyxHQUFHLElBQUksa0JBQWtCLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQTtJQUV2RSxNQUFNLHNCQUFzQixHQUFHLGlCQUFpQixJQUFJLGtCQUFrQixLQUFLLEdBQUcsQ0FBQTtJQUc5RSxJQUFJLGlCQUFpQixFQUFFLENBQUM7UUFDdEIsTUFBTSxZQUFZLEdBQUcsc0JBQXNCO1lBQ3pDLENBQUMsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxVQUFVLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLEdBQUcsQ0FBQyxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUM7WUFDdEQsQ0FBQyxDQUFDLElBQUksTUFBTSxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUE7UUFFbkQsT0FBTyxZQUFZLENBQUE7SUFDckIsQ0FBQztJQUdELE9BQU8sS0FBSyxLQUFLLFVBQVUsQ0FBQTtBQUM3QixDQUFDO0FBRUQsa0JBQWU7SUFDYixFQUFFLEVBQUUsbUJBQW1CO0lBQ3ZCLFdBQVcsRUFBRSw4Q0FBOEM7SUFDM0QsSUFBSSxDQUFDLE1BQU0sRUFBRSxRQUFRLEVBQUUsT0FBTztRQUM1QixNQUFNLFVBQVUsR0FBRyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsTUFBTSxDQUMvRCxpQkFBaUIsQ0FDbEIsQ0FBQTtRQUVELE1BQU0sQ0FBQyxXQUFXLENBQUMsaUJBQWlCLEVBQUUsQ0FBQyxLQUFLLEVBQUUsRUFBRTtZQUM5QyxNQUFNLE9BQU8sR0FBRyxLQUFLLENBQUMsT0FBTyxDQUFBO1lBQzdCLElBQ0UsQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUMsR0FBRyxFQUFFLEVBQUUsQ0FBQyx5QkFBeUIsQ0FBQyxPQUFPLEVBQUUsR0FBRyxDQUFDLENBQUM7Z0JBQ2xFLE9BQU8sS0FBSyxPQUFPLENBQUMsV0FBVyxFQUFFLEVBQ2pDLENBQUM7Z0JBQ0QsUUFBUSxDQUFDLEtBQUssQ0FDWiw4QkFBOEIsT0FBTywwQkFBMEIsRUFDL0QsS0FBSyxDQUFDLElBQUksRUFDVixLQUFLLENBQUMsR0FBRyxFQUNULElBQUksRUFDSixLQUFLLENBQUMsR0FBRyxDQUNWLENBQUE7WUFDSCxDQUFDO1FBQ0gsQ0FBQyxDQUFDLENBQUE7SUFDSixDQUFDO0NBQ00sQ0FBQSJ9 \ No newline at end of file diff --git a/src/core/rules/tagname-lowercase.ts b/src/core/rules/tagname-lowercase.ts index fefaa2d0a..99af0a736 100644 --- a/src/core/rules/tagname-lowercase.ts +++ b/src/core/rules/tagname-lowercase.ts @@ -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 +} + export default { id: 'tagname-lowercase', description: 'All html element names must be in lowercase.', init(parser, reporter, options) { - const exceptions: Array = 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( diff --git a/src/core/types.ts b/src/core/types.ts index 003476994..e4b6d83fd 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 'tagname-specialchars'?: boolean 'tags-check'?: { [tagName: string]: Record } 'title-require'?: boolean diff --git a/test/rules/tagname-lowercase.spec.js b/test/rules/tagname-lowercase.spec.js index dd116d851..0ccdbd2dd 100644 --- a/test/rules/tagname-lowercase.spec.js +++ b/test/rules/tagname-lowercase.spec.js @@ -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 = + '' + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(0) + }) + + it('Known SVG elements should be ignored with a config override', () => { + const code = '' + 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 = '' + ruleOptions[ruleId] = ['CustomComponent', 'AnotherCamelCase'] + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(0) + ruleOptions[ruleId] = true + }) }) diff --git a/website/src/content/docs/rules/tagname-lowercase.mdx b/website/src/content/docs/rules/tagname-lowercase.mdx index f0d26d7f4..283a9cc13 100644 --- a/website/src/content/docs/rules/tagname-lowercase.mdx +++ b/website/src/content/docs/rules/tagname-lowercase.mdx @@ -14,7 +14,7 @@ Level: - `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` ### The following patterns are **not** considered rule violations @@ -22,6 +22,12 @@ Level:
``` +SVG elements with camelCase names (e.g. `linearGradient`, `foreignObject`) are allowed: + +```html + +``` + ### The following pattern is considered a rule violation: ```html