UNPKG

eslint-plugin-tailwindcss

Version:
222 lines (203 loc) 8.15 kB
/** * @fileoverview Detect classnames which do not belong to Tailwind CSS * @author no-custom-classname */ 'use strict'; 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); }, };