Skip to content

Commit 5e7504d

Browse files
committed
feat(consistent-selector-style): added rule implementation
1 parent 0136b24 commit 5e7504d

File tree

5 files changed

+308
-41
lines changed

5 files changed

+308
-41
lines changed

.changeset/spotty-kings-fry.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': minor
3+
---
4+
5+
feat: added the `consistent-selector-style` rule

packages/eslint-plugin-svelte/src/rule-types.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface RuleOptions {
3838
* enforce a consistent style for CSS selectors
3939
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/consistent-selector-style/
4040
*/
41-
'svelte/consistent-selector-style'?: Linter.RuleEntry<[]>
41+
'svelte/consistent-selector-style'?: Linter.RuleEntry<SvelteConsistentSelectorStyle>
4242
/**
4343
* derived store should use same variable names between values and callback
4444
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/derived-has-same-inputs-outputs/
@@ -392,6 +392,11 @@ type SvelteButtonHasType = []|[{
392392
type SvelteCommentDirective = []|[{
393393
reportUnusedDisableDirectives?: boolean
394394
}]
395+
// ----- svelte/consistent-selector-style -----
396+
type SvelteConsistentSelectorStyle = []|[{
397+
398+
style?: [("class" | "id" | "type"), ("class" | "id" | "type"), ("class" | "id" | "type")]
399+
}]
395400
// ----- svelte/first-attribute-linebreak -----
396401
type SvelteFirstAttributeLinebreak = []|[{
397402
multiline?: ("below" | "beside")
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,282 @@
1-
import { createRule } from '../utils';
1+
import type { AST } from 'svelte-eslint-parser';
2+
import type { AnyNode } from 'postcss';
3+
import type {
4+
ClassName as SelectorClass,
5+
Identifier as SelectorIdentifier,
6+
Node as SelectorNode,
7+
Tag as SelectorTag
8+
} from 'postcss-selector-parser';
9+
import { findClassesInAttribute } from '../utils/ast-utils.js';
10+
import { getSourceCode } from '../utils/compat.js';
11+
import { createRule } from '../utils/index.js';
12+
import type { RuleContext, SourceCode } from '../types.js';
13+
14+
interface RuleGlobals {
15+
style: string[];
16+
classSelections: Map<string, AST.SvelteHTMLElement[]>;
17+
idSelections: Map<string, AST.SvelteHTMLElement[]>;
18+
typeSelections: Map<string, AST.SvelteHTMLElement[]>;
19+
context: RuleContext;
20+
parserServices: SourceCode['parserServices'];
21+
}
222

323
export default createRule('consistent-selector-style', {
424
meta: {
525
docs: {
626
description: 'enforce a consistent style for CSS selectors',
727
category: 'Stylistic Issues',
8-
recommended: false
28+
recommended: false,
29+
conflictWithPrettier: false
30+
},
31+
schema: [
32+
{
33+
type: 'object',
34+
properties: {
35+
// TODO: Add option to include global selectors
36+
style: {
37+
type: 'array',
38+
items: {
39+
enum: ['class', 'id', 'type']
40+
},
41+
minItems: 3, // TODO: Allow fewer items
42+
maxItems: 3,
43+
uniqueItems: true
44+
}
45+
},
46+
additionalProperties: false
47+
}
48+
],
49+
messages: {
50+
classShouldBeId: 'Selector should select by ID instead of class',
51+
classShouldBeType: 'Selector should select by element type instead of class',
52+
idShouldBeClass: 'Selector should select by class instead of ID',
53+
idShouldBeType: 'Selector should select by element type instead of ID',
54+
typeShouldBeClass: 'Selector should select by class instead of element type',
55+
typeShouldBeId: 'Selector should select by ID instead of element type'
956
},
10-
schema: [],
11-
messages: {},
1257
type: 'suggestion'
1358
},
1459
create(context) {
15-
return {};
60+
const sourceCode = getSourceCode(context);
61+
if (!sourceCode.parserServices.isSvelte) {
62+
return {};
63+
}
64+
65+
const style = context.options[0]?.style ?? ['type', 'id', 'class'];
66+
67+
const classSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
68+
const idSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
69+
const typeSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
70+
71+
return {
72+
SvelteElement(node) {
73+
if (node.kind !== 'html') {
74+
return;
75+
}
76+
addToArrayMap(typeSelections, node.name.name, node);
77+
const classes = node.startTag.attributes.flatMap(findClassesInAttribute);
78+
for (const className of classes) {
79+
addToArrayMap(classSelections, className, node);
80+
}
81+
for (const attribute of node.startTag.attributes) {
82+
if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') {
83+
continue;
84+
}
85+
for (const value of attribute.value) {
86+
if (value.type === 'SvelteLiteral') {
87+
addToArrayMap(idSelections, value.value, node);
88+
}
89+
}
90+
}
91+
},
92+
'Program:exit'() {
93+
const styleContext = sourceCode.parserServices.getStyleContext!();
94+
if (styleContext.status !== 'success') {
95+
return;
96+
}
97+
checkSelectorsInPostCSSNode(styleContext.sourceAst, {
98+
style,
99+
classSelections,
100+
idSelections,
101+
typeSelections,
102+
context,
103+
parserServices: sourceCode.parserServices
104+
});
105+
}
106+
};
16107
}
17108
});
109+
110+
/**
111+
* Helper function to add a value to a Map of arrays
112+
*/
113+
function addToArrayMap(
114+
map: Map<string, AST.SvelteHTMLElement[]>,
115+
key: string,
116+
value: AST.SvelteHTMLElement
117+
): void {
118+
map.set(key, (map.get(key) ?? []).concat(value));
119+
}
120+
121+
/**
122+
* Checks selectors in a given PostCSS node
123+
*/
124+
function checkSelectorsInPostCSSNode(node: AnyNode, ruleGlobals: RuleGlobals): void {
125+
if (node.type === 'rule') {
126+
checkSelector(ruleGlobals.parserServices.getStyleSelectorAST(node), ruleGlobals);
127+
}
128+
if (
129+
(node.type === 'root' ||
130+
(node.type === 'rule' && node.selector !== ':global') ||
131+
node.type === 'atrule') &&
132+
node.nodes !== undefined
133+
) {
134+
node.nodes.flatMap((node) => checkSelectorsInPostCSSNode(node, ruleGlobals));
135+
}
136+
}
137+
138+
/**
139+
* Checks an individual selector
140+
*/
141+
function checkSelector(node: SelectorNode, ruleGlobals: RuleGlobals): void {
142+
if (node.type === 'class') {
143+
checkClassSelector(node, ruleGlobals);
144+
}
145+
if (node.type === 'id') {
146+
checkIdSelector(node, ruleGlobals);
147+
}
148+
if (node.type === 'tag') {
149+
checkTypeSelector(node, ruleGlobals);
150+
}
151+
if (
152+
(node.type === 'pseudo' && node.value !== ':global') ||
153+
node.type === 'root' ||
154+
node.type === 'selector'
155+
) {
156+
node.nodes.flatMap((node) => checkSelector(node, ruleGlobals));
157+
}
158+
}
159+
160+
/**
161+
* Checks a class selector
162+
*/
163+
function checkClassSelector(node: SelectorClass, ruleGlobals: RuleGlobals): void {
164+
const selection = ruleGlobals.classSelections.get(node.value) ?? [];
165+
for (const styleValue of ruleGlobals.style) {
166+
if (styleValue === 'class') {
167+
return;
168+
}
169+
if (styleValue === 'id' && couldBeId(selection)) {
170+
ruleGlobals.context.report({
171+
messageId: 'classShouldBeId',
172+
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.SourceLocation
173+
});
174+
return;
175+
}
176+
if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) {
177+
ruleGlobals.context.report({
178+
messageId: 'classShouldBeType',
179+
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.SourceLocation
180+
});
181+
return;
182+
}
183+
}
184+
}
185+
186+
/**
187+
* Checks an ID selector
188+
*/
189+
function checkIdSelector(node: SelectorIdentifier, ruleGlobals: RuleGlobals): void {
190+
const selection = ruleGlobals.idSelections.get(node.value) ?? [];
191+
for (const styleValue of ruleGlobals.style) {
192+
if (styleValue === 'class') {
193+
ruleGlobals.context.report({
194+
messageId: 'idShouldBeClass',
195+
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.SourceLocation
196+
});
197+
return;
198+
}
199+
if (styleValue === 'id') {
200+
return;
201+
}
202+
if (styleValue === 'type' && couldBeType(selection, ruleGlobals.typeSelections)) {
203+
ruleGlobals.context.report({
204+
messageId: 'idShouldBeType',
205+
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.SourceLocation
206+
});
207+
return;
208+
}
209+
}
210+
}
211+
212+
/**
213+
* Checks a type selector
214+
*/
215+
function checkTypeSelector(node: SelectorTag, ruleGlobals: RuleGlobals): void {
216+
const selection = ruleGlobals.typeSelections.get(node.value) ?? [];
217+
for (const styleValue of ruleGlobals.style) {
218+
if (styleValue === 'class') {
219+
ruleGlobals.context.report({
220+
messageId: 'typeShouldBeClass',
221+
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.SourceLocation
222+
});
223+
return;
224+
}
225+
if (styleValue === 'id' && couldBeId(selection)) {
226+
ruleGlobals.context.report({
227+
messageId: 'typeShouldBeId',
228+
loc: ruleGlobals.parserServices.styleSelectorNodeLoc(node) as AST.SourceLocation
229+
});
230+
return;
231+
}
232+
if (styleValue === 'type') {
233+
return;
234+
}
235+
}
236+
}
237+
238+
/**
239+
* Checks whether a given selection could be obtained using an ID selector
240+
*/
241+
function couldBeId(selection: AST.SvelteHTMLElement[]): boolean {
242+
return selection.length <= 1;
243+
}
244+
245+
/**
246+
* Checks whether a given selection could be obtained using a type selector
247+
*/
248+
function couldBeType(
249+
selection: AST.SvelteHTMLElement[],
250+
typeSelections: Map<string, AST.SvelteHTMLElement[]>
251+
): boolean {
252+
const types = new Set(selection.map((node) => node.name.name));
253+
if (types.size > 1) {
254+
return false;
255+
}
256+
if (types.size < 1) {
257+
return true;
258+
}
259+
const type = [...types][0];
260+
const typeSelection = typeSelections.get(type);
261+
return typeSelection !== undefined && arrayEquals(typeSelection, selection);
262+
}
263+
264+
/**
265+
* Compares two arrays for item equality
266+
*/
267+
function arrayEquals(array1: AST.SvelteHTMLElement[], array2: AST.SvelteHTMLElement[]): boolean {
268+
function comparator(a: AST.SvelteHTMLElement, b: AST.SvelteHTMLElement): number {
269+
return a.range[0] - b.range[0];
270+
}
271+
272+
const array2Sorted = array2.slice().sort(comparator);
273+
return (
274+
array1.length === array2.length &&
275+
array1
276+
.slice()
277+
.sort(comparator)
278+
.every(function (value, index) {
279+
return value === array2Sorted[index];
280+
})
281+
);
282+
}

packages/eslint-plugin-svelte/src/rules/no-unused-class-name.ts

+3-35
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { createRule } from '../utils/index.js';
2-
import type {
3-
SourceLocation,
4-
SvelteAttribute,
5-
SvelteDirective,
6-
SvelteGenericsDirective,
7-
SvelteShorthandAttribute,
8-
SvelteSpecialDirective,
9-
SvelteSpreadAttribute,
10-
SvelteStyleDirective
11-
} from 'svelte-eslint-parser/lib/ast';
2+
import type { AST } from 'svelte-eslint-parser';
123
import type { AnyNode } from 'postcss';
134
import { default as selectorParser, type Node as SelectorNode } from 'postcss-selector-parser';
5+
import { findClassesInAttribute } from '../utils/ast-utils.js';
146
import { getSourceCode } from '../utils/compat.js';
157

168
export default createRule('no-unused-class-name', {
@@ -43,7 +35,7 @@ export default createRule('no-unused-class-name', {
4335
return {};
4436
}
4537
const allowedClassNames = context.options[0]?.allowedClassNames ?? [];
46-
const classesUsedInTemplate: Record<string, SourceLocation> = {};
38+
const classesUsedInTemplate: Record<string, AST.SourceLocation> = {};
4739

4840
return {
4941
SvelteElement(node) {
@@ -75,30 +67,6 @@ export default createRule('no-unused-class-name', {
7567
}
7668
});
7769

78-
/**
79-
* Extract all class names used in a HTML element attribute.
80-
*/
81-
function findClassesInAttribute(
82-
attribute:
83-
| SvelteAttribute
84-
| SvelteShorthandAttribute
85-
| SvelteSpreadAttribute
86-
| SvelteDirective
87-
| SvelteStyleDirective
88-
| SvelteSpecialDirective
89-
| SvelteGenericsDirective
90-
): string[] {
91-
if (attribute.type === 'SvelteAttribute' && attribute.key.name === 'class') {
92-
return attribute.value.flatMap((value) =>
93-
value.type === 'SvelteLiteral' ? value.value.trim().split(/\s+/u) : []
94-
);
95-
}
96-
if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') {
97-
return [attribute.key.name.name];
98-
}
99-
return [];
100-
}
101-
10270
/**
10371
* Extract all class names used in a PostCSS node.
10472
*/

0 commit comments

Comments
 (0)