Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/two-hats-ask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-svelte': minor
---

feat(consistent-selector-style): added support for dynamic classes and IDs
101 changes: 81 additions & 20 deletions packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,21 @@ import type {
Node as SelectorNode,
Tag as SelectorTag
} from 'postcss-selector-parser';
import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast';
import { findClassesInAttribute } from '../utils/ast-utils.js';
import {
extractExpressionPrefixLiteral,
extractExpressionSuffixLiteral
} from '../utils/expression-affixes.js';
import { createRule } from '../utils/index.js';

interface Selections {
exact: Map<string, AST.SvelteHTMLElement[]>;
// [prefix, suffix]
affixes: Map<[string | null, string | null], AST.SvelteHTMLElement[]>;
universalSelector: boolean;
}

export default createRule('consistent-selector-style', {
meta: {
docs: {
Expand Down Expand Up @@ -62,9 +74,24 @@ export default createRule('consistent-selector-style', {
const style = context.options[0]?.style ?? ['type', 'id', 'class'];

const whitelistedClasses: string[] = [];
const classSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
const idSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();
const typeSelections: Map<string, AST.SvelteHTMLElement[]> = new Map();

const selections: {
class: Selections;
id: Selections;
type: Map<string, AST.SvelteHTMLElement[]>;
} = {
class: {
exact: new Map(),
affixes: new Map(),
universalSelector: false
},
id: {
exact: new Map(),
affixes: new Map(),
universalSelector: false
},
type: new Map()
};

/**
* Checks selectors in a given PostCSS node
Expand Down Expand Up @@ -109,10 +136,10 @@ export default createRule('consistent-selector-style', {
* Checks a class selector
*/
function checkClassSelector(node: SelectorClass): void {
if (whitelistedClasses.includes(node.value)) {
if (selections.class.universalSelector || whitelistedClasses.includes(node.value)) {
return;
}
const selection = classSelections.get(node.value) ?? [];
const selection = matchSelection(selections.class, node.value);
for (const styleValue of style) {
if (styleValue === 'class') {
return;
Expand All @@ -124,7 +151,7 @@ export default createRule('consistent-selector-style', {
});
return;
}
if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) {
if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) {
context.report({
messageId: 'classShouldBeType',
loc: styleSelectorNodeLoc(node) as AST.SourceLocation
Expand All @@ -138,7 +165,10 @@ export default createRule('consistent-selector-style', {
* Checks an ID selector
*/
function checkIdSelector(node: SelectorIdentifier): void {
const selection = idSelections.get(node.value) ?? [];
if (selections.id.universalSelector) {
return;
}
const selection = matchSelection(selections.id, node.value);
for (const styleValue of style) {
if (styleValue === 'class') {
context.report({
Expand All @@ -150,7 +180,7 @@ export default createRule('consistent-selector-style', {
if (styleValue === 'id') {
return;
}
if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) {
if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) {
context.report({
messageId: 'idShouldBeType',
loc: styleSelectorNodeLoc(node) as AST.SourceLocation
Expand All @@ -164,7 +194,7 @@ export default createRule('consistent-selector-style', {
* Checks a type selector
*/
function checkTypeSelector(node: SelectorTag): void {
const selection = typeSelections.get(node.value) ?? [];
const selection = selections.type.get(node.value) ?? [];
for (const styleValue of style) {
if (styleValue === 'class') {
context.report({
Expand All @@ -191,21 +221,39 @@ export default createRule('consistent-selector-style', {
if (node.kind !== 'html') {
return;
}
addToArrayMap(typeSelections, node.name.name, node);
const classes = node.startTag.attributes.flatMap(findClassesInAttribute);
for (const className of classes) {
addToArrayMap(classSelections, className, node);
}
addToArrayMap(selections.type, node.name.name, node);
for (const attribute of node.startTag.attributes) {
if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') {
whitelistedClasses.push(attribute.key.name.name);
}
if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') {
for (const className of findClassesInAttribute(attribute)) {
addToArrayMap(selections.class.exact, className, node);
}
if (attribute.type !== 'SvelteAttribute') {
continue;
}
for (const value of attribute.value) {
if (value.type === 'SvelteLiteral') {
addToArrayMap(idSelections, value.value, node);
if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') {
const prefix = extractExpressionPrefixLiteral(context, value.expression);
const suffix = extractExpressionSuffixLiteral(context, value.expression);
if (prefix === null && suffix === null) {
selections.class.universalSelector = true;
} else {
addToArrayMap(selections.class.affixes, [prefix, suffix], node);
}
}
if (attribute.key.name === 'id') {
if (value.type === 'SvelteLiteral') {
addToArrayMap(selections.id.exact, value.value, node);
} else if (value.type === 'SvelteMustacheTag') {
const prefix = extractExpressionPrefixLiteral(context, value.expression);
const suffix = extractExpressionSuffixLiteral(context, value.expression);
if (prefix === null && suffix === null) {
selections.id.universalSelector = true;
} else {
addToArrayMap(selections.id.affixes, [prefix, suffix], node);
}
}
}
}
}
Expand All @@ -227,14 +275,27 @@ export default createRule('consistent-selector-style', {
/**
* Helper function to add a value to a Map of arrays
*/
function addToArrayMap(
map: Map<string, AST.SvelteHTMLElement[]>,
key: string,
function addToArrayMap<T>(
map: Map<T, AST.SvelteHTMLElement[]>,
key: T,
value: AST.SvelteHTMLElement
): void {
map.set(key, (map.get(key) ?? []).concat(value));
}

/**
* Finds all nodes in selections that could be matched by key
*/
function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] {
const selection = selections.exact.get(key) ?? [];
selections.affixes.forEach((nodes, [prefix, suffix]) => {
if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) {
selection.push(...nodes);
}
});
return selection;
}

/**
* Checks whether a given selection could be obtained using an ID selector
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TSESTree } from '@typescript-eslint/types';
import { createRule } from '../utils/index.js';
import { ReferenceTracker } from '@eslint-community/eslint-utils';
import { findVariable } from '../utils/ast-utils.js';
import { extractExpressionPrefixVariable } from '../utils/expression-affixes.js';
import type { RuleContext } from '../types.js';
import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast';

Expand Down Expand Up @@ -221,87 +222,8 @@ function expressionStartsWithBase(
url: TSESTree.Expression,
basePathNames: Set<TSESTree.Identifier>
): boolean {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionStartsWithBase(context, url, basePathNames);
case 'Identifier':
return variableStartsWithBase(context, url, basePathNames);
case 'MemberExpression':
return memberExpressionStartsWithBase(url, basePathNames);
case 'TemplateLiteral':
return templateLiteralStartsWithBase(context, url, basePathNames);
default:
return false;
}
}

function binaryExpressionStartsWithBase(
context: RuleContext,
url: TSESTree.BinaryExpression,
basePathNames: Set<TSESTree.Identifier>
): boolean {
return (
url.left.type !== 'PrivateIdentifier' &&
expressionStartsWithBase(context, url.left, basePathNames)
);
}

function memberExpressionStartsWithBase(
url: TSESTree.MemberExpression,
basePathNames: Set<TSESTree.Identifier>
): boolean {
return url.property.type === 'Identifier' && basePathNames.has(url.property);
}

function variableStartsWithBase(
context: RuleContext,
url: TSESTree.Identifier,
basePathNames: Set<TSESTree.Identifier>
): boolean {
if (basePathNames.has(url)) {
return true;
}
const variable = findVariable(context, url);
if (
variable === null ||
variable.identifiers.length !== 1 ||
variable.identifiers[0].parent.type !== 'VariableDeclarator' ||
variable.identifiers[0].parent.init === null
) {
return false;
}
return expressionStartsWithBase(context, variable.identifiers[0].parent.init, basePathNames);
}

function templateLiteralStartsWithBase(
context: RuleContext,
url: TSESTree.TemplateLiteral,
basePathNames: Set<TSESTree.Identifier>
): boolean {
const startingIdentifier = extractLiteralStartingExpression(url);
return (
startingIdentifier !== undefined &&
expressionStartsWithBase(context, startingIdentifier, basePathNames)
);
}

function extractLiteralStartingExpression(
templateLiteral: TSESTree.TemplateLiteral
): TSESTree.Expression | undefined {
const literalParts = [...templateLiteral.expressions, ...templateLiteral.quasis].sort((a, b) =>
a.range[0] < b.range[0] ? -1 : 1
);
for (const part of literalParts) {
if (part.type === 'TemplateElement' && part.value.raw === '') {
// Skip empty quasi in the begining
continue;
}
if (part.type !== 'TemplateElement') {
return part;
}
return undefined;
}
return undefined;
const prefixVariable = extractExpressionPrefixVariable(context, url);
return prefixVariable !== null && basePathNames.has(prefixVariable);
}

function expressionIsEmpty(url: TSESTree.Expression): boolean {
Expand Down
Loading
Loading