UNPKG

eslint-plugin-svelte-tailwindcss

Version:
719 lines (697 loc) 22.5 kB
'use strict'; const svelteParser = require('svelte-eslint-parser'); const fs = require('node:fs'); const path = require('node:path'); const pkg = require('enhanced-resolve'); const synckit = require('synckit'); const node_url = require('node:url'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } function _interopNamespaceCompat(e) { if (e && typeof e === 'object' && 'default' in e) return e; const n = Object.create(null); if (e) { for (const k in e) { n[k] = e[k]; } } n.default = e; return n; } const svelteParser__namespace = /*#__PURE__*/_interopNamespaceCompat(svelteParser); const fs__default = /*#__PURE__*/_interopDefaultCompat(fs); const path__default = /*#__PURE__*/_interopDefaultCompat(path); const pkg__default = /*#__PURE__*/_interopDefaultCompat(pkg); const name = "eslint-plugin-svelte-tailwindcss"; const version = "1.1.0"; const rules$1 = { "svelte-tailwindcss/at-apply-require-postcss": "error", "svelte-tailwindcss/no-literal-mustache-mix": "error", "svelte-tailwindcss/sort-classes": ["error", { config: "./src/app.css" }] }; const flatConfig = [ { name: "svelte-tailwindcss:base", plugins: { get "svelte-tailwindcss"() { return plugin; } } }, { files: ["*.svelte", "**/*.svelte"], languageOptions: { parser: svelteParser__namespace }, name: "svelte-tailwindcss:base:svelte-setup", rules: rules$1 } ]; const SEP_REGEX = /([\t\n\f\r ]+)/; const getCallExpressionCalleeName = ({ callee: node }) => { if (node.type === "Identifier") { return node.name; } if (node.type === "MemberExpression") { if ("name" in node.object && "name" in node.property) { return `${node.object.name}.${node.property.name}`; } } return null; }; const getTemplateElementPrefix = (text, raw) => text.indexOf(raw) === 0 ? "" : text.split(raw).shift(); const getTemplateElementSuffix = (text, raw) => !text.includes(raw) ? "" : text.split(raw).pop(); const getTemplateElementBody = (text, prefix, suffix) => { let arr = text.split(prefix); arr.shift(); const body = arr.join(prefix); arr = body.split(suffix); arr.pop(); return arr.join(suffix); }; const extractClassnamesFromValue = (value) => { if (typeof value !== "string") { return { classNames: [], headSpace: false, tailSpace: false, whitespaces: [] }; } const parts = value.split(SEP_REGEX); if (parts[0] === "") { parts.shift(); } if (parts[parts.length - 1] === "") { parts.pop(); } const headSpace = SEP_REGEX.test(parts[0]); const tailSpace = SEP_REGEX.test(parts[parts.length - 1]); return { classNames: parts.filter((_, i) => headSpace ? i % 2 !== 0 : i % 2 === 0), headSpace, tailSpace, whitespaces: parts.filter((_, i) => headSpace ? i % 2 === 0 : i % 2 !== 0) }; }; const workerDir = node_url.fileURLToPath(new URL( // Since e2e are also run with MODE === 'test', we change the env.MODE in the // vitest configuration to e2e-test, this way the e2e try to find the cjs // file over the ts file undefined?.MODE === "test" ? "../workers/config-v4.ts" : "./workers/config-v4.cjs", (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)) )); const EXPECTED_FILE_TYPES = [ ".cjs", ".cts", ".js", ".mjs", ".mts", ".svelte", ".ts" ]; const isExpectedFileType = (ext) => EXPECTED_FILE_TYPES.includes(ext); const getFileType = (file) => { const ext = path__default.extname(file); return isExpectedFileType(ext) ? ext : null; }; const isTsOrJsFile = (fileType) => !(fileType === ".svelte" || fileType === null); const createRule = ({ create, defaultOptions, meta }) => ({ create: (context) => { const optionsWithDefault = context.options.map((options, index) => ({ ...defaultOptions[index] || {}, ...options || {} })); return create(context, optionsWithDefault); }, defaultOptions, meta }); const createNamedRule = ({ meta, ...rule }) => createRule({ meta: { ...meta, docs: { ...meta.docs } }, ...rule }); const { CachedInputFileSystem, ResolverFactory } = pkg__default; const fileSystem = new CachedInputFileSystem(fs__default, 3e4); const jsonResolver = ResolverFactory.createResolver({ conditionNames: ["json"], extensions: [".json"], fileSystem, useSyncFileSystemCalls: true }); const parseSemanticVersion = (version) => { const [major, minor, patchString] = version.split("."); const [patch, identifier] = patchString.split("-"); return { identifier, major: +major, minor: +minor, patch: +patch }; }; const twVersionCache = /* @__PURE__ */ new Map(); const getTailwindcssVersion = (fileName) => { const cached = twVersionCache.get(fileName); if (cached) { return parseSemanticVersion(cached); } const packageJsonPath = jsonResolver.resolveSync({}, process.cwd(), "tailwindcss/package.json"); if (!packageJsonPath) { throw new Error("Could not find a Tailwind CSS package.json"); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); if (!packageJson) { throw new Error("Could not find a Tailwind CSS package.json"); } const { version } = packageJson.version; twVersionCache.set(fileName, version); return parseSemanticVersion(version); }; const VALID_CONFIG_FILES = [ "tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs", "tailwind.config.ts", "tailwind.config.cts", "tailwind.config.mts" ]; const DEFAULT_CONFIG = { callees: ["classnames", "clsx", "ctl", "cva", "tv"], classRegex: "^class(Name)?$", config: "./src/app.css", declarations: {}, ignoredKeys: ["compoundVariants", "defaultVariants"], monorepo: false, removeDuplicates: true, skipClassAttribute: false, tags: [], whitelist: [] }; const getParent = (pathname) => pathname.split(path__default.sep).slice(0, -1).join(path__default.sep); const findParentConfigFile = (cwd, folder, config) => { if (!folder.startsWith(cwd)) { throw new Error( "Unable to find config file. `monorepo` setting was set to true, yet not tailwind configuration was found. Make sure you have a tailwind config file." ); } for (const current of fs.readdirSync(folder)) { if (config === current) { return path__default.join(folder, config); } for (const valid of VALID_CONFIG_FILES) { if (valid === current) { return path__default.join(folder, valid); } } } return findParentConfigFile(cwd, path__default.dirname(folder), config); }; const getOption = (context, name) => { if (context.options && context.options.length) { if (context.options[0][name] !== void 0) { return context.options[0][name]; } } if (context.settings.tailwindcss) { const settingValue = context.settings.tailwindcss[name]; if (settingValue !== void 0) { return settingValue; } } return DEFAULT_CONFIG[name]; }; const getMonorepoConfig = (context) => { if (getTailwindcssVersion(context.filename).major === 4) { throw new Error("The `monorepo` option is not allowed for v4"); } const config = getOption(context, "config"); const fileFolder = getParent(context.filename); return findParentConfigFile(context.cwd, fileFolder, config); }; const bigSign = (value) => Number(value > 0n) - Number(value < 0n); const bigIntSorter = ([, a], [, z]) => { if (a === z) { return 0; } if (a === null) { return -1; } if (z === null) { return 1; } return bigSign(a - z); }; const getClassOrderSync = synckit.createSyncFn(workerDir, undefined?.MODE === "test" ? { tsRunner: "tsx" } : void 0); const sortClasses$1 = (className, twConfig) => { if (typeof className !== "string" || className === "") { return className; } if (className.includes("{{")) { return className; } const parts = className.split(/([\t\n\f\r ]+)/); const classes = parts.filter((_, i) => i % 2 === 0); const whitespace = parts.filter((_, i) => i % 2 !== 0); if (classes[classes.length - 1] === "") { classes.pop(); } const result = getClassOrderSync(twConfig, classes).sort(bigIntSorter).map(([className2]) => className2).reduce( (acc, className2, i) => `${acc}${className2}${whitespace[i] ?? ""}`, "" ); return result; }; const atApplyRequirePostcss = createNamedRule({ create(rootContext) { const { sourceCode } = rootContext; const context = sourceCode.parserServices.getStyleContext?.(); if (!context || context.status !== "success") { return {}; } return { SvelteStyleElement(node) { const startTag = node.startTag; let classAttribute = null; const hasPostcssLangAttr = startTag.attributes.some((attr) => { if (attr.type !== "SvelteAttribute") { return false; } classAttribute = attr; const { key, value } = attr; const attrValue = value[0]; return key.name === "lang" && attrValue.type === "SvelteLiteral" && attrValue.value === "postcss"; }); if (hasPostcssLangAttr) { return; } const includesApply = context.sourceAst.walk((node2) => { if (node2.type !== "atrule") { return void 0; } return node2.name === "apply" ? false : void 0; }); if (includesApply === void 0) { return; } rootContext.report({ fix: (fixer) => { if (classAttribute) { return fixer.replaceText(classAttribute, 'lang="postcss"'); } return fixer.insertTextBeforeRange([startTag.range[1] - 1, startTag.range[1]], ' lang="postcss"'); }, messageId: "require-postcss", node }); } }; }, defaultOptions: [], meta: { docs: { description: "undefined" }, fixable: "code", messages: { "require-postcss": "Using '@apply' requires setting style lang to postcss" }, schema: [{ properties: {}, type: "object" }], type: "problem" }, name: "at-apply-require-postcss" }); const CLASS_PREFIX = "class="; const noLiteralMustacheMix = createNamedRule({ create(context) { const src = context.sourceCode; return { "SvelteStartTag > SvelteAttribute": (node) => { if (node.key.name !== "class") { return; } if (node.value.length < 2) { return; } const nodeText = src.getText(node).slice(CLASS_PREFIX.length).slice(1, -1); let mustachesFirst = false; const [literals, mustaches] = node.value.reduce( (acc, expr, i) => { if (expr.type === "SvelteLiteral") { acc[0].push(expr); } else if (expr.type === "SvelteMustacheTag") { acc[1].push(expr); if (!mustachesFirst && i === 0) { mustachesFirst = true; } } return acc; }, [[], []] ); const joinedLiterals = literals.map((l) => l.value.trim()).filter(Boolean).join(" "); const joinedMustaches = mustaches.map((m) => src.getText(m)).join(" "); const result = (mustachesFirst ? `${joinedMustaches} ${joinedLiterals}` : `${joinedLiterals} ${joinedMustaches}`).trim(); if (result !== nodeText) { context.report({ fix: (fixer) => fixer.replaceTextRange([ node.range[0] + CLASS_PREFIX.length + 1, node.range[1] - 1 ], result), messageId: "no-mix", node }); } } }; }, defaultOptions: [], meta: { docs: { description: "undefined" }, fixable: "code", messages: { // Add message ids here - note minimum one message is required "no-mix": "Do not mix literal expressions with mustache expressions" }, schema: [], type: "suggestion" }, name: "no-literal-mustache-mix" }); const sortLiteral = (literal, twConfig) => { if (!literal.value || typeof literal.value !== "string") { return null; } return sortClasses$1(literal.value, twConfig); }; const removeDuplicatesOrOriginal = (original, removeDuplicates = false, trim = true) => { if (!removeDuplicates) { return original; } const splitted = original.split(SEP_REGEX); let result = ""; let last = ""; for (let i = 0; i < splitted.length; i++) { const value = splitted[i]; if (!value.trim() || value === last) { continue; } last = value; result += `${value} `; } return result[result.length - 1] === " " && trim ? result.slice(0, -1) : result; }; const removeDuplicatesOrOriginalWithSpaces = ({ headSpace, original, removeDuplicates = false, tailSpace, whitespaces }) => { if (!removeDuplicates) { return { classes: original, spaces: whitespaces }; } const offset = !headSpace && !tailSpace || tailSpace ? -1 : 0; let previous = original[0]; const classes = [previous]; const whitespacesToRemoveIndices = []; for (let i = 1; i < original.length; i++) { const cls = original[i]; if (cls === previous) { const wsIndex = i + offset - whitespacesToRemoveIndices.filter((index) => index < i + offset).length; whitespacesToRemoveIndices.push(wsIndex); } else { classes.push(cls); previous = cls; } } const spaces = [...whitespaces]; whitespacesToRemoveIndices.sort((a, b) => b - a); for (const index of whitespacesToRemoveIndices) { if (index >= 0 && index < spaces.length) { spaces.splice(index, 1); } } return { classes, spaces }; }; const sortClasses = createNamedRule({ create(context) { const callees = getOption(context, "callees"); const declarations = getOption(context, "declarations"); const monorepo = getOption(context, "monorepo"); const twConfig = monorepo ? getMonorepoConfig(context) : getOption(context, "config"); const removeDuplicates = getOption(context, "removeDuplicates"); const isValidDeclarator = (node) => { if (node.id?.type !== "Identifier") { return false; } else if (!node.init) { return false; } const fnName = node.id.name; const isPrefix = (declarations.prefix ?? []).some((prefix) => fnName.startsWith(prefix)); const isSuffix = (declarations.suffix ?? []).some((suffix) => fnName.endsWith(suffix)); const isName = (declarations.names ?? []).includes(fnName); return isPrefix || isSuffix || isName; }; const sortNodeArgumentValue = (node, arg) => { let originalClassNamesValue = null; let start = null; let end = null; let prefix = ""; let suffix = ""; switch (arg.type) { case "ArrayExpression": arg.elements.forEach((arg2) => { if (arg2) { sortNodeArgumentValue(node, arg2); } }); return; case "BinaryExpression": sortNodeArgumentValue(node, arg.left); sortNodeArgumentValue(node, arg.right); return; case "ConditionalExpression": sortNodeArgumentValue(node, arg.consequent); sortNodeArgumentValue(node, arg.alternate); return; case "Literal": originalClassNamesValue = arg.value; start = arg.range[0] + 1; end = arg.range[1] - 1; break; case "LogicalExpression": sortNodeArgumentValue(node, arg.right); return; case "ObjectExpression": { arg.properties.forEach((prop) => { if ("key" in prop) { sortNodeArgumentValue(node, prop.key); sortNodeArgumentValue(node, prop.value); } }); return; } case "ReturnStatement": if (!arg.argument) { return; } sortNodeArgumentValue(node, arg.argument); break; case "SvelteLiteral": originalClassNamesValue = arg.value; start = arg.range[0]; end = arg.range[1]; break; case "TemplateElement": { originalClassNamesValue = arg.value.raw; if (originalClassNamesValue === "") { return; } start = arg.range[0]; end = arg.range[1]; const text = context.sourceCode.getText(arg); prefix = getTemplateElementPrefix(text, originalClassNamesValue); suffix = getTemplateElementSuffix(text, originalClassNamesValue); originalClassNamesValue = getTemplateElementBody(text, prefix, suffix); break; } case "TemplateLiteral": arg.expressions.forEach((arg2) => { sortNodeArgumentValue(node, arg2); }); arg.quasis.forEach((arg2) => { sortNodeArgumentValue(node, arg2); }); return; case "VariableDeclarator": if (!isValidDeclarator(arg)) { return; } sortNodeArgumentValue(node, arg.init); break; default: return; } if (start === null || end === null) { return; } const { classNames, headSpace, tailSpace, whitespaces } = extractClassnamesFromValue(originalClassNamesValue); if (classNames.length <= 1) { return; } const { classes, spaces } = removeDuplicatesOrOriginalWithSpaces({ headSpace, original: (sortClasses$1(classNames.join(" "), twConfig) ?? "").split(" "), removeDuplicates, tailSpace, whitespaces }); const validatedClasses = classes.reduce((acc, cls, i, arr) => { const space = spaces[i] ?? ""; if (i === arr.length - 1 && headSpace && tailSpace) { return `${acc}${space}${cls}${spaces[spaces.length - 1]}`; } return acc + (headSpace ? `${space}${cls}` : `${cls}${space}`); }, ""); if (originalClassNamesValue !== validatedClasses) { context.report({ fix: (fixer) => fixer.replaceTextRange([start, end], `${prefix}${validatedClasses}${suffix}`), messageId: "sort-classes", node }); } }; const callExpressionListener = (node) => { const calleName = getCallExpressionCalleeName(node); if (callees.findIndex((name) => calleName === name) === -1) { return; } node.arguments.forEach((arg) => { sortNodeArgumentValue(node, arg); }); }; const declaratorListener = (node) => { if (!isValidDeclarator(node)) { return; } sortNodeArgumentValue(node, node.init); }; const commonListeners = { CallExpression: callExpressionListener, VariableDeclarator: declaratorListener }; if (isTsOrJsFile(getFileType(context.filename))) { return commonListeners; } return { ...Object.entries(commonListeners).reduce((acc, [key, listener]) => ({ ...acc, [`SvelteScriptElement ${key}`]: listener }), {}), "SvelteStartTag > SvelteAttribute": (node) => { if (node.key.name !== "class") { return; } node.value.forEach((expr, i) => { if (expr.type === "SvelteLiteral") { sortNodeArgumentValue(node, expr); return; } if (expr.expression.type === "Literal") { const sorted = removeDuplicatesOrOriginal( sortLiteral(expr.expression, twConfig) ?? "", removeDuplicates, i === node.value.length - 1 ); if (!sorted || sorted === expr.expression.value) { return; } context.report({ // While the {} are redundant, it's not the rule's task to // ensure code cleanliness. In order to remove unnecessary // brackets, use svelte/no-useless-mustaches from // svelte-eslint-plugin // https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/ fix: (fixer) => fixer.replaceTextRange(expr.range, `{"${sorted}"}`), messageId: "sort-classes", node: expr }); } else if (expr.expression.type === "CallExpression") { callExpressionListener(expr.expression); } else { sortNodeArgumentValue(node, expr.expression); } }); } }; }, defaultOptions: [{}], meta: { docs: { description: "Sort Tailwind CSS classes" }, fixable: "code", messages: { "sort-classes": "TailwindCSS classes should be sorted" }, schema: [{ properties: { callees: { items: { minLength: 0, type: "string" }, type: "array", uniqueItems: true }, config: { type: ["string", "object"] }, declarations: { additionalProperties: false, properties: { names: { items: { type: "string" }, type: "array", uniqueItems: true }, prefix: { items: { type: "string" }, type: "array", uniqueItems: true }, suffix: { items: { type: "string" }, type: "array", uniqueItems: true } }, type: "object" }, ignoredKeys: { items: { minLength: 0, type: "string" }, type: "array", uniqueItems: true }, monorepo: { type: "boolean" }, removeDuplicates: { type: "boolean" }, tags: { items: { minLength: 0, type: "string" }, type: "array", uniqueItems: true } }, type: "object" }], type: "suggestion" }, name: "sort-classes" }); const rules = { "at-apply-require-postcss": atApplyRequirePostcss, "no-literal-mustache-mix": noLiteralMustacheMix, "sort-classes": sortClasses }; const plugin = { configs: { base: flatConfig }, meta: { name: name, version }, rules }; module.exports = plugin;