Skip to content

Commit a2a3160

Browse files
committed
feat(consistent-selector-style): matching dynamic class name prefixes
1 parent cc96baa commit a2a3160

File tree

3 files changed

+138
-9
lines changed

3 files changed

+138
-9
lines changed

packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ import type {
66
Node as SelectorNode,
77
Tag as SelectorTag
88
} from 'postcss-selector-parser';
9+
import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast';
910
import { findClassesInAttribute } from '../utils/ast-utils.js';
1011
import { getSourceCode } from '../utils/compat.js';
12+
import { extractExpressionPrefixLiteral } from '../utils/expression-affixes.js';
1113
import { createRule } from '../utils/index.js';
1214

15+
interface Selections {
16+
exact: Map<string, AST.SvelteHTMLElement[]>;
17+
prefixes: Map<string, AST.SvelteHTMLElement[]>;
18+
}
19+
1320
export default createRule('consistent-selector-style', {
1421
meta: {
1522
docs: {
@@ -65,11 +72,14 @@ export default createRule('consistent-selector-style', {
6572
const whitelistedClasses: string[] = [];
6673

6774
const selections: {
68-
class: Map<string, AST.SvelteHTMLElement[]>;
75+
class: Selections;
6976
id: Map<string, AST.SvelteHTMLElement[]>;
7077
type: Map<string, AST.SvelteHTMLElement[]>;
7178
} = {
72-
class: new Map(),
79+
class: {
80+
exact: new Map(),
81+
prefixes: new Map()
82+
},
7383
id: new Map(),
7484
type: new Map()
7585
};
@@ -120,7 +130,7 @@ export default createRule('consistent-selector-style', {
120130
if (whitelistedClasses.includes(node.value)) {
121131
return;
122132
}
123-
const selection = selections.class.get(node.value) ?? [];
133+
const selection = matchSelection(selections.class, node.value);
124134
for (const styleValue of style) {
125135
if (styleValue === 'class') {
126136
return;
@@ -200,19 +210,24 @@ export default createRule('consistent-selector-style', {
200210
return;
201211
}
202212
addToArrayMap(selections.type, node.name.name, node);
203-
const classes = node.startTag.attributes.flatMap(findClassesInAttribute);
204-
for (const className of classes) {
205-
addToArrayMap(selections.class, className, node);
206-
}
207213
for (const attribute of node.startTag.attributes) {
208214
if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') {
209215
whitelistedClasses.push(attribute.key.name.name);
210216
}
211-
if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') {
217+
for (const className of findClassesInAttribute(attribute)) {
218+
addToArrayMap(selections.class.exact, className, node);
219+
}
220+
if (attribute.type !== 'SvelteAttribute') {
212221
continue;
213222
}
214223
for (const value of attribute.value) {
215-
if (value.type === 'SvelteLiteral') {
224+
if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') {
225+
const prefix = extractExpressionPrefixLiteral(context, value.expression);
226+
if (prefix !== null) {
227+
addToArrayMap(selections.class.prefixes, prefix, node);
228+
}
229+
}
230+
if (attribute.key.name === 'id' && value.type === 'SvelteLiteral') {
216231
addToArrayMap(selections.id, value.value, node);
217232
}
218233
}
@@ -243,6 +258,19 @@ function addToArrayMap(
243258
map.set(key, (map.get(key) ?? []).concat(value));
244259
}
245260

261+
/**
262+
* Finds all nodes in selections that could be matched by key
263+
*/
264+
function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] {
265+
const selection = selections.exact.get(key) ?? [];
266+
selections.prefixes.forEach((nodes, prefix) => {
267+
if (key.startsWith(prefix)) {
268+
selection.push(...nodes);
269+
}
270+
});
271+
return selection;
272+
}
273+
246274
/**
247275
* Checks whether a given selection could be obtained using an ID selector
248276
*/

packages/eslint-plugin-svelte/src/utils/expression-affixes.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TSESTree } from '@typescript-eslint/types';
22
import { findVariable } from './ast-utils.js';
33
import type { RuleContext } from '../types.js';
4+
import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast';
45

56
// Variable prefix extraction
67

@@ -74,3 +75,70 @@ function extractTemplateLiteralPrefixVariable(
7475
}
7576
return null;
7677
}
78+
79+
// Literal prefix extraction
80+
81+
export function extractExpressionPrefixLiteral(
82+
context: RuleContext,
83+
expression: SvelteLiteral | TSESTree.Node
84+
): string | null {
85+
switch (expression.type) {
86+
case 'BinaryExpression':
87+
return extractBinaryExpressionPrefixLiteral(context, expression);
88+
case 'Identifier':
89+
return extractVariablePrefixLiteral(context, expression);
90+
case 'Literal':
91+
return typeof expression.value === 'string' ? expression.value : null;
92+
case 'SvelteLiteral':
93+
return expression.value;
94+
case 'TemplateLiteral':
95+
return extractTemplateLiteralPrefixLiteral(context, expression);
96+
default:
97+
return null;
98+
}
99+
}
100+
101+
function extractBinaryExpressionPrefixLiteral(
102+
context: RuleContext,
103+
expression: TSESTree.BinaryExpression
104+
): string | null {
105+
return expression.left.type !== 'PrivateIdentifier'
106+
? extractExpressionPrefixLiteral(context, expression.left)
107+
: null;
108+
}
109+
110+
function extractVariablePrefixLiteral(
111+
context: RuleContext,
112+
expression: TSESTree.Identifier
113+
): string | null {
114+
const variable = findVariable(context, expression);
115+
if (
116+
variable === null ||
117+
variable.identifiers.length !== 1 ||
118+
variable.identifiers[0].parent.type !== 'VariableDeclarator' ||
119+
variable.identifiers[0].parent.init === null
120+
) {
121+
return null;
122+
}
123+
return extractExpressionPrefixLiteral(context, variable.identifiers[0].parent.init);
124+
}
125+
126+
function extractTemplateLiteralPrefixLiteral(
127+
context: RuleContext,
128+
expression: TSESTree.TemplateLiteral
129+
): string | null {
130+
const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) =>
131+
a.range[0] < b.range[0] ? -1 : 1
132+
);
133+
for (const part of literalParts) {
134+
if (part.type === 'TemplateElement') {
135+
if (part.value.raw === '') {
136+
// Skip empty quasi in the begining
137+
continue;
138+
}
139+
return part.value.raw;
140+
}
141+
return extractExpressionPrefixLiteral(context, part);
142+
}
143+
return null;
144+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script>
2+
import { value } from "package";
3+
4+
const derived = "link-three-" + value;
5+
</script>
6+
7+
<a>Click me!</a>
8+
9+
<a class={"link-one-" + value}>Click me two!</a>
10+
11+
<a class={"link-one-" + value}>Click me two!</a>
12+
13+
<a class={`link-two-${value}`}>Click me three!</a>
14+
15+
<a class={`link-two-${value}`}>Click me three!</a>
16+
17+
<a class={derived}>Click me four!</a>
18+
19+
<a class={derived}>Click me four!</a>
20+
21+
<style>
22+
.link-one-foo {
23+
color: red;
24+
}
25+
26+
.link-two-foo {
27+
color: red;
28+
}
29+
30+
.link-three-foo {
31+
color: red;
32+
}
33+
</style>

0 commit comments

Comments
 (0)