eslint-plugin-tailwindcss
Version:
Rules enforcing best practices while using Tailwind CSS
222 lines (203 loc) • 8.15 kB
JavaScript
/**
* @fileoverview Detect classnames which do not belong to Tailwind CSS
* @author no-custom-classname
*/
;
const docsUrl = require('../util/docsUrl');
const defaultGroups = require('../config/groups').groups;
const customConfig = require('../util/customConfig');
const astUtil = require('../util/ast');
const groupUtil = require('../util/groupMethods');
const getOption = require('../util/settings');
const parserUtil = require('../util/parser');
const getClassnamesFromCSS = require('../util/cssFiles');
const createContextFallback = require('tailwindcss/lib/lib/setupContextUtils').createContext;
const generated = require('../util/generated');
const escapeRegex = require('../util/regex').escapeRegex;
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
// Predefine message for use in context.report conditional.
// messageId will still be usable in tests.
const CUSTOM_CLASSNAME_DETECTED_MSG = `Classname '{{classname}}' is not a Tailwind CSS class!`;
// Group/peer names can be arbitrarily named and are not
// generated by generateRules. Using a custom regexp to
// validate these avoids false reports.
const getGroupNameRegex = (prefix = '') =>
new RegExp(`^${escapeRegex(prefix)}(group|peer)\/[\\w\\$\\#\\@\\%\\^\\&\\*\\_\\-]+$`, 'i');
const contextFallbackCache = new WeakMap();
module.exports = {
meta: {
docs: {
description: 'Detect classnames which do not belong to Tailwind CSS',
category: 'Best Practices',
recommended: false,
url: docsUrl('no-custom-classname'),
},
messages: {
customClassnameDetected: CUSTOM_CLASSNAME_DETECTED_MSG,
},
fixable: null,
schema: [
{
type: 'object',
properties: {
callees: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
ignoredKeys: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
config: {
// returned from `loadConfig()` utility
type: ['string', 'object'],
},
cssFiles: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
cssFilesRefreshRate: {
type: 'number',
// default: 5_000,
},
tags: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
whitelist: {
type: 'array',
items: { type: 'string', minLength: 0 },
uniqueItems: true,
},
},
},
],
},
create: function (context) {
const callees = getOption(context, 'callees');
const ignoredKeys = getOption(context, 'ignoredKeys');
const skipClassAttribute = getOption(context, 'skipClassAttribute');
const tags = getOption(context, 'tags');
const twConfig = getOption(context, 'config');
const cssFiles = getOption(context, 'cssFiles');
const cssFilesRefreshRate = getOption(context, 'cssFilesRefreshRate');
const whitelist = getOption(context, 'whitelist');
const classRegex = getOption(context, 'classRegex');
const mergedConfig = customConfig.resolve(twConfig);
const contextFallback = // Set the created contextFallback in the cache if it does not exist yet.
(
contextFallbackCache.has(mergedConfig)
? contextFallbackCache
: contextFallbackCache.set(mergedConfig, createContextFallback(mergedConfig))
).get(mergedConfig);
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
// Init assets before sorting
const groups = groupUtil.getGroups(defaultGroups, mergedConfig);
const classnamesFromFiles = getClassnamesFromCSS(cssFiles, cssFilesRefreshRate);
const groupNameRegex = getGroupNameRegex(mergedConfig.prefix);
/**
* Parse the classnames and report found conflicts
* @param {Array} classNames
* @param {ASTNode} node
*/
const parseForCustomClassNames = (classNames, node) => {
classNames.forEach((className) => {
const gen = generated(className, contextFallback);
if (gen.length) {
return; // Lazier is faster... processing next className!
}
const idx = groupUtil.getGroupIndex(className, groups, mergedConfig.separator);
if (idx >= 0) {
return; // Lazier is faster... processing next className!
}
const whitelistIdx = groupUtil.getGroupIndex(className, whitelist, mergedConfig.separator);
if (whitelistIdx >= 0) {
return; // Lazier is faster... processing next className!
}
const fromFilesIdx = groupUtil.getGroupIndex(className, classnamesFromFiles, mergedConfig.separator);
if (fromFilesIdx >= 0) {
return; // Lazier is faster... processing next className!
}
if (groupNameRegex.test(className)) {
return; // Lazier is faster... processing next className!
}
// No match found
context.report({
node,
messageId: 'customClassnameDetected',
data: {
classname: className,
},
});
});
};
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
const attributeVisitor = function (node) {
if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) {
return;
}
if (astUtil.isLiteralAttributeValue(node)) {
astUtil.parseNodeRecursive(node, null, parseForCustomClassNames, false, false, ignoredKeys);
} else if (node.value && node.value.type === 'JSXExpressionContainer') {
astUtil.parseNodeRecursive(node, node.value.expression, parseForCustomClassNames, false, false, ignoredKeys);
}
};
const callExpressionVisitor = function (node) {
const calleeStr = astUtil.calleeToString(node.callee);
if (callees.findIndex((name) => calleeStr === name) === -1) {
return;
}
node.arguments.forEach((arg) => {
astUtil.parseNodeRecursive(node, arg, parseForCustomClassNames, false, false, ignoredKeys);
});
};
const scriptVisitor = {
JSXAttribute: attributeVisitor,
TextAttribute: attributeVisitor,
CallExpression: callExpressionVisitor,
TaggedTemplateExpression: function (node) {
if (!tags.includes(node.tag.name ?? node.tag.object?.name ?? node.tag.callee?.name)) {
return;
}
astUtil.parseNodeRecursive(node, node.quasi, parseForCustomClassNames, false, false, ignoredKeys);
},
};
const templateVisitor = {
CallExpression: callExpressionVisitor,
/*
Tagged templates inside data bindings
https://github.com/vuejs/vue/issues/9721
*/
VAttribute: function (node) {
switch (true) {
case !astUtil.isValidVueAttribute(node, classRegex):
return;
case astUtil.isVLiteralValue(node):
astUtil.parseNodeRecursive(node, null, parseForCustomClassNames, false, false, ignoredKeys);
break;
case astUtil.isArrayExpression(node):
node.value.expression.elements.forEach((arg) => {
astUtil.parseNodeRecursive(node, arg, parseForCustomClassNames, false, false, ignoredKeys);
});
break;
case astUtil.isObjectExpression(node):
node.value.expression.properties.forEach((prop) => {
astUtil.parseNodeRecursive(node, prop, parseForCustomClassNames, false, false, ignoredKeys);
});
break;
}
},
};
return parserUtil.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor);
},
};