eslint-plugin-svelte-tailwindcss
Version:
ESLint plugin for Svelte and Tailwind CSS
719 lines (697 loc) • 22.5 kB
JavaScript
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;
;