Skip to content

Commit 87c74fe

Browse files
feat(consistent-selector-style): added support for dynamic classes and IDs (#1148)
Co-authored-by: Yosuke Ota <[email protected]>
1 parent 73f23ae commit 87c74fe

10 files changed

+465
-101
lines changed

.changeset/two-hats-ask.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': minor
3+
---
4+
5+
feat(consistent-selector-style): added support for dynamic classes and IDs

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

+81-20
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,21 @@ 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';
11+
import {
12+
extractExpressionPrefixLiteral,
13+
extractExpressionSuffixLiteral
14+
} from '../utils/expression-affixes.js';
1015
import { createRule } from '../utils/index.js';
1116

17+
interface Selections {
18+
exact: Map<string, AST.SvelteHTMLElement[]>;
19+
// [prefix, suffix]
20+
affixes: Map<[string | null, string | null], AST.SvelteHTMLElement[]>;
21+
universalSelector: boolean;
22+
}
23+
1224
export default createRule('consistent-selector-style', {
1325
meta: {
1426
docs: {
@@ -62,9 +74,24 @@ export default createRule('consistent-selector-style', {
6274
const style = context.options[0]?.style ?? ['type', 'id', 'class'];
6375

6476
const whitelistedClasses: string[] = [];
65-
const classSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
66-
const idSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
67-
const typeSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
77+
78+
const selections: {
79+
class: Selections;
80+
id: Selections;
81+
type: Map<string, AST.SvelteHTMLElement[]>;
82+
} = {
83+
class: {
84+
exact: new Map(),
85+
affixes: new Map(),
86+
universalSelector: false
87+
},
88+
id: {
89+
exact: new Map(),
90+
affixes: new Map(),
91+
universalSelector: false
92+
},
93+
type: new Map()
94+
};
6895

6996
/**
7097
* Checks selectors in a given PostCSS node
@@ -109,10 +136,10 @@ export default createRule('consistent-selector-style', {
109136
* Checks a class selector
110137
*/
111138
function checkClassSelector(node: SelectorClass): void {
112-
if (whitelistedClasses.includes(node.value)) {
139+
if (selections.class.universalSelector || whitelistedClasses.includes(node.value)) {
113140
return;
114141
}
115-
const selection = classSelections.get(node.value) ?? [];
142+
const selection = matchSelection(selections.class, node.value);
116143
for (const styleValue of style) {
117144
if (styleValue === 'class') {
118145
return;
@@ -124,7 +151,7 @@ export default createRule('consistent-selector-style', {
124151
});
125152
return;
126153
}
127-
if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) {
154+
if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) {
128155
context.report({
129156
messageId: 'classShouldBeType',
130157
loc: styleSelectorNodeLoc(node) as AST.SourceLocation
@@ -138,7 +165,10 @@ export default createRule('consistent-selector-style', {
138165
* Checks an ID selector
139166
*/
140167
function checkIdSelector(node: SelectorIdentifier): void {
141-
const selection = idSelections.get(node.value) ?? [];
168+
if (selections.id.universalSelector) {
169+
return;
170+
}
171+
const selection = matchSelection(selections.id, node.value);
142172
for (const styleValue of style) {
143173
if (styleValue === 'class') {
144174
context.report({
@@ -150,7 +180,7 @@ export default createRule('consistent-selector-style', {
150180
if (styleValue === 'id') {
151181
return;
152182
}
153-
if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) {
183+
if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) {
154184
context.report({
155185
messageId: 'idShouldBeType',
156186
loc: styleSelectorNodeLoc(node) as AST.SourceLocation
@@ -164,7 +194,7 @@ export default createRule('consistent-selector-style', {
164194
* Checks a type selector
165195
*/
166196
function checkTypeSelector(node: SelectorTag): void {
167-
const selection = typeSelections.get(node.value) ?? [];
197+
const selection = selections.type.get(node.value) ?? [];
168198
for (const styleValue of style) {
169199
if (styleValue === 'class') {
170200
context.report({
@@ -191,21 +221,39 @@ export default createRule('consistent-selector-style', {
191221
if (node.kind !== 'html') {
192222
return;
193223
}
194-
addToArrayMap(typeSelections, node.name.name, node);
195-
const classes = node.startTag.attributes.flatMap(findClassesInAttribute);
196-
for (const className of classes) {
197-
addToArrayMap(classSelections, className, node);
198-
}
224+
addToArrayMap(selections.type, node.name.name, node);
199225
for (const attribute of node.startTag.attributes) {
200226
if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') {
201227
whitelistedClasses.push(attribute.key.name.name);
202228
}
203-
if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') {
229+
for (const className of findClassesInAttribute(attribute)) {
230+
addToArrayMap(selections.class.exact, className, node);
231+
}
232+
if (attribute.type !== 'SvelteAttribute') {
204233
continue;
205234
}
206235
for (const value of attribute.value) {
207-
if (value.type === 'SvelteLiteral') {
208-
addToArrayMap(idSelections, value.value, node);
236+
if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') {
237+
const prefix = extractExpressionPrefixLiteral(context, value.expression);
238+
const suffix = extractExpressionSuffixLiteral(context, value.expression);
239+
if (prefix === null && suffix === null) {
240+
selections.class.universalSelector = true;
241+
} else {
242+
addToArrayMap(selections.class.affixes, [prefix, suffix], node);
243+
}
244+
}
245+
if (attribute.key.name === 'id') {
246+
if (value.type === 'SvelteLiteral') {
247+
addToArrayMap(selections.id.exact, value.value, node);
248+
} else if (value.type === 'SvelteMustacheTag') {
249+
const prefix = extractExpressionPrefixLiteral(context, value.expression);
250+
const suffix = extractExpressionSuffixLiteral(context, value.expression);
251+
if (prefix === null && suffix === null) {
252+
selections.id.universalSelector = true;
253+
} else {
254+
addToArrayMap(selections.id.affixes, [prefix, suffix], node);
255+
}
256+
}
209257
}
210258
}
211259
}
@@ -227,14 +275,27 @@ export default createRule('consistent-selector-style', {
227275
/**
228276
* Helper function to add a value to a Map of arrays
229277
*/
230-
function addToArrayMap(
231-
map: Map<string, AST.SvelteHTMLElement[]>,
232-
key: string,
278+
function addToArrayMap<T>(
279+
map: Map<T, AST.SvelteHTMLElement[]>,
280+
key: T,
233281
value: AST.SvelteHTMLElement
234282
): void {
235283
map.set(key, (map.get(key) ?? []).concat(value));
236284
}
237285

286+
/**
287+
* Finds all nodes in selections that could be matched by key
288+
*/
289+
function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] {
290+
const selection = selections.exact.get(key) ?? [];
291+
selections.affixes.forEach((nodes, [prefix, suffix]) => {
292+
if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) {
293+
selection.push(...nodes);
294+
}
295+
});
296+
return selection;
297+
}
298+
238299
/**
239300
* Checks whether a given selection could be obtained using an ID selector
240301
*/

packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts

+3-81
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { TSESTree } from '@typescript-eslint/types';
22
import { createRule } from '../utils/index.js';
33
import { ReferenceTracker } from '@eslint-community/eslint-utils';
44
import { findVariable } from '../utils/ast-utils.js';
5+
import { extractExpressionPrefixVariable } from '../utils/expression-affixes.js';
56
import type { RuleContext } from '../types.js';
67
import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast';
78

@@ -221,87 +222,8 @@ function expressionStartsWithBase(
221222
url: TSESTree.Expression,
222223
basePathNames: Set<TSESTree.Identifier>
223224
): boolean {
224-
switch (url.type) {
225-
case 'BinaryExpression':
226-
return binaryExpressionStartsWithBase(context, url, basePathNames);
227-
case 'Identifier':
228-
return variableStartsWithBase(context, url, basePathNames);
229-
case 'MemberExpression':
230-
return memberExpressionStartsWithBase(url, basePathNames);
231-
case 'TemplateLiteral':
232-
return templateLiteralStartsWithBase(context, url, basePathNames);
233-
default:
234-
return false;
235-
}
236-
}
237-
238-
function binaryExpressionStartsWithBase(
239-
context: RuleContext,
240-
url: TSESTree.BinaryExpression,
241-
basePathNames: Set<TSESTree.Identifier>
242-
): boolean {
243-
return (
244-
url.left.type !== 'PrivateIdentifier' &&
245-
expressionStartsWithBase(context, url.left, basePathNames)
246-
);
247-
}
248-
249-
function memberExpressionStartsWithBase(
250-
url: TSESTree.MemberExpression,
251-
basePathNames: Set<TSESTree.Identifier>
252-
): boolean {
253-
return url.property.type === 'Identifier' && basePathNames.has(url.property);
254-
}
255-
256-
function variableStartsWithBase(
257-
context: RuleContext,
258-
url: TSESTree.Identifier,
259-
basePathNames: Set<TSESTree.Identifier>
260-
): boolean {
261-
if (basePathNames.has(url)) {
262-
return true;
263-
}
264-
const variable = findVariable(context, url);
265-
if (
266-
variable === null ||
267-
variable.identifiers.length !== 1 ||
268-
variable.identifiers[0].parent.type !== 'VariableDeclarator' ||
269-
variable.identifiers[0].parent.init === null
270-
) {
271-
return false;
272-
}
273-
return expressionStartsWithBase(context, variable.identifiers[0].parent.init, basePathNames);
274-
}
275-
276-
function templateLiteralStartsWithBase(
277-
context: RuleContext,
278-
url: TSESTree.TemplateLiteral,
279-
basePathNames: Set<TSESTree.Identifier>
280-
): boolean {
281-
const startingIdentifier = extractLiteralStartingExpression(url);
282-
return (
283-
startingIdentifier !== undefined &&
284-
expressionStartsWithBase(context, startingIdentifier, basePathNames)
285-
);
286-
}
287-
288-
function extractLiteralStartingExpression(
289-
templateLiteral: TSESTree.TemplateLiteral
290-
): TSESTree.Expression | undefined {
291-
const literalParts = [...templateLiteral.expressions, ...templateLiteral.quasis].sort((a, b) =>
292-
a.range[0] < b.range[0] ? -1 : 1
293-
);
294-
for (const part of literalParts) {
295-
if (part.type === 'TemplateElement' && part.value.raw === '') {
296-
// Skip empty quasi in the begining
297-
continue;
298-
}
299-
if (part.type !== 'TemplateElement') {
300-
return part;
301-
}
302-
return undefined;
303-
}
304-
return undefined;
225+
const prefixVariable = extractExpressionPrefixVariable(context, url);
226+
return prefixVariable !== null && basePathNames.has(prefixVariable);
305227
}
306228

307229
function expressionIsEmpty(url: TSESTree.Expression): boolean {

0 commit comments

Comments
 (0)