Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(consistent-selector-style): added support for dynamic classes and IDs #1148

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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,10 +6,22 @@ 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 { getSourceCode } from '../utils/compat.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 @@ -63,9 +75,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 @@ -110,10 +137,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 @@ -125,7 +152,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 @@ -139,7 +166,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 @@ -151,7 +181,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 @@ -165,7 +195,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 @@ -192,21 +222,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 @@ -228,14 +276,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 @@ -3,6 +3,7 @@ import { createRule } from '../utils/index.js';
import { ReferenceTracker } from '@eslint-community/eslint-utils';
import { getSourceCode } from '../utils/compat.js';
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 @@ -224,87 +225,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