From c809658ae0cc9995da8facb1920cb54bf7f7d1e3 Mon Sep 17 00:00:00 2001 From: Diana Suvorova Date: Tue, 1 Apr 2025 11:00:04 -0700 Subject: [PATCH] feat: no-custom-classname adding @html-eslint/parser support and alpinejs aware syntax handling --- lib/rules/no-custom-classname.js | 39 +++++++++++++++++++++ package-lock.json | 45 ++++++++++++++++++++++++ package.json | 1 + tests/lib/rules/no-custom-classname.js | 47 ++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) diff --git a/lib/rules/no-custom-classname.js b/lib/rules/no-custom-classname.js index 97216be9..151c905f 100644 --- a/lib/rules/no-custom-classname.js +++ b/lib/rules/no-custom-classname.js @@ -177,6 +177,43 @@ module.exports = { }); }; + // @html-eslint/parser node (with proper Alpinejs handling) + const htmlElsintParserVisitor = function (node) { + const attrName = node.key?.value; + const rawValue = node.value?.value; + + const ALPINE_MARKERS = new Set(['x-transition', 'x-cloak']); + + // alpinejs attributes, to be ignored + if (!attrName || attrName.startsWith('x-')) return; + if (typeof rawValue !== 'string') return; + + if (attrName === 'class') { + const classNames = rawValue + .split(/\s+/) + .filter((name) => name && !ALPINE_MARKERS.has(name)); + parseForCustomClassNames(classNames, node); + } + + if (attrName === ':class') { + // Only extract keys in object-style bindings: { 'foo bar': condition } + const matches = rawValue.match(/'([^']+?)'\s*:/g) || []; + const classNames = []; + + for (const match of matches) { + const rawKey = match.replace(/['":]/g, '').trim(); + rawKey.split(/\s+/).forEach((name) => { + if (name && !ALPINE_MARKERS.has(name)) { + classNames.push(name); + } + }); + } + + parseForCustomClassNames(classNames, node); + } + }; + + const scriptVisitor = { JSXAttribute: attributeVisitor, TextAttribute: attributeVisitor, @@ -187,6 +224,8 @@ module.exports = { } astUtil.parseNodeRecursive(node, node.quasi, parseForCustomClassNames, false, false, ignoredKeys); }, + Attribute: htmlElsintParserVisitor, + }; const templateVisitor = { diff --git a/package-lock.json b/package-lock.json index 08c57dd4..d3522018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@angular-eslint/template-parser": "^15.2.0", + "@html-eslint/parser": "^0.37.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/line-clamp": "^0.4.2", @@ -132,6 +133,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@html-eslint/parser": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@html-eslint/parser/-/parser-0.37.0.tgz", + "integrity": "sha512-shrBuyEsa8iilhRrUlArULkleVlsNZVPXVulejmq7DkxNj1bQyYVFRYHIkhg5GkbPZT6wrUeA2SLOJ2xanpYIA==", + "dev": true, + "dependencies": { + "@html-eslint/template-syntax-parser": "^0.37.0", + "es-html-parser": "0.1.1" + } + }, + "node_modules/@html-eslint/template-syntax-parser": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@html-eslint/template-syntax-parser/-/template-syntax-parser-0.37.0.tgz", + "integrity": "sha512-xL/OVGJOJQ8G3akZFlQeADPMX9OTVrUwMaAGrwIewf4k7lxnpz8sDaFgpkA5X7AYzmhIeENrhaB/pIbC0KaSyQ==", + "dev": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -944,6 +961,12 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/es-html-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/es-html-parser/-/es-html-parser-0.1.1.tgz", + "integrity": "sha512-SNHdEpKkN4nWZ3sFq9AxPlaUzPKJewGh59JrVS2355vELTOFygyf/lbfDDIONuGvYrhvAHoaUd+sK9UGaGrKUg==", + "dev": true + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -2924,6 +2947,22 @@ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true }, + "@html-eslint/parser": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@html-eslint/parser/-/parser-0.37.0.tgz", + "integrity": "sha512-shrBuyEsa8iilhRrUlArULkleVlsNZVPXVulejmq7DkxNj1bQyYVFRYHIkhg5GkbPZT6wrUeA2SLOJ2xanpYIA==", + "dev": true, + "requires": { + "@html-eslint/template-syntax-parser": "^0.37.0", + "es-html-parser": "0.1.1" + } + }, + "@html-eslint/template-syntax-parser": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@html-eslint/template-syntax-parser/-/template-syntax-parser-0.37.0.tgz", + "integrity": "sha512-xL/OVGJOJQ8G3akZFlQeADPMX9OTVrUwMaAGrwIewf4k7lxnpz8sDaFgpkA5X7AYzmhIeENrhaB/pIbC0KaSyQ==", + "dev": true + }, "@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -3510,6 +3549,12 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "es-html-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/es-html-parser/-/es-html-parser-0.1.1.tgz", + "integrity": "sha512-SNHdEpKkN4nWZ3sFq9AxPlaUzPKJewGh59JrVS2355vELTOFygyf/lbfDDIONuGvYrhvAHoaUd+sK9UGaGrKUg==", + "dev": true + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", diff --git a/package.json b/package.json index 85edb84e..b134da35 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@angular-eslint/template-parser": "^15.2.0", + "@html-eslint/parser": "^0.37.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/line-clamp": "^0.4.2", diff --git a/tests/lib/rules/no-custom-classname.js b/tests/lib/rules/no-custom-classname.js index 4259aad6..33b8f692 100644 --- a/tests/lib/rules/no-custom-classname.js +++ b/tests/lib/rules/no-custom-classname.js @@ -1123,7 +1123,28 @@ ruleTester.run("no-custom-classname", rule, { code: ``, filename: "test.vue", parser: require.resolve("vue-eslint-parser"), + }, + { + code: `
`, + parser: require.resolve("@html-eslint/parser"), + }, + { + code: `
Alpine.js x-data
`, + parser: require.resolve("@html-eslint/parser"), + }, + { + code: `
`, + parser: require.resolve("@html-eslint/parser"), + }, + { + code: `
Should ignore Alpine extras
`, + parser: require.resolve("@html-eslint/parser"), + }, + { + code: `
Hybrid class binding
`, + parser: require.resolve("@html-eslint/parser"), } + ], invalid: [ @@ -1610,5 +1631,31 @@ ruleTester.run("no-custom-classname", rule, { code: `
Subgrid support
`, errors: generateErrors("grid-rows-supagrid"), }, + { + code: `
Invalid HTML classes
`, + parser: require.resolve("@html-eslint/parser"), + errors: generateErrors("unknown-class tailwind-fail"), + }, + { + code: `
`, + parser: require.resolve("@html-eslint/parser"), + errors: generateErrors("bg-custom text-💥"), + }, + { + code: `
Basic + invalid class
`, + parser: require.resolve("@html-eslint/parser"), + errors: generateErrors("custom-btn"), + }, + { + code: `
`, + parser: require.resolve("@html-eslint/parser"), + errors: generateErrors("foo-bar bar-baz"), + }, + { + code: `
`, + parser: require.resolve("@html-eslint/parser"), + errors: generateErrors("bg-offwhite text-neon"), + } + ], });