UNPKG

eslint-plugin-better-tailwindcss

Version:

auto-wraps tailwind classes after a certain print width or class count into multiple lines to improve readability.

501 lines 21.2 kB
import { boolean, description, literal, minValue, number, optional, pipe, strictObject, union } from "valibot"; import { createGetDissectedClasses, getDissectedClasses } from "../tailwindcss/dissect-classes.js"; import { async } from "../utils/context.js"; import { escapeNestedQuotes } from "../utils/quotes.js"; import { createRule } from "../utils/rule.js"; import { display, splitClasses } from "../utils/utils.js"; export const enforceConsistentLineWrapping = createRule({ autofix: true, category: "stylistic", description: "Enforce consistent line wrapping for tailwind classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-consistent-line-wrapping.md", name: "enforce-consistent-line-wrapping", recommended: true, messages: { missing: "Incorrect line wrapping. Expected\n\n{{ notReadable }}\n\nto be\n\n{{ readable }}", unnecessary: "Unnecessary line wrapping. Expected\n\n{{ notReadable }}\n\nto be\n\n{{ readable }}" }, schema: strictObject({ classesPerLine: optional(pipe(number(), minValue(0), description("The maximum amount of classes per line.")), 0), group: optional(pipe(union([ literal("newLine"), literal("emptyLine"), literal("never") ]), description("Defines how different groups of classes should be separated.")), "newLine"), indent: optional(pipe(union([ literal("tab"), pipe(number(), minValue(0)) ]), description("Determines how the code should be indented.")), 2), lineBreakStyle: optional(pipe(union([literal("unix"), literal("windows")]), description("The line break style.")), "unix"), preferSingleLine: optional(pipe(boolean(), description("Prefer a single line for different variants.")), false), printWidth: optional(pipe(number(), minValue(0), description("The maximum line length before it gets wrapped.")), 80), strictness: optional(pipe(union([ literal("strict"), literal("loose") ]), description("Enable this option if prettier is used in your project.")), "strict") }), initialize: ctx => { createGetDissectedClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx, literals) { const { classesPerLine, group: groupSeparator, messageStyle, preferSingleLine, printWidth, strictness } = ctx.options; for (const literal of literals) { if (!literal.supportsMultiline) { continue; } const lineStartPosition = literal.indentation + getIndentation(ctx); const literalStartPosition = literal.loc.start.column; const prettierStartPosition = lineStartPosition + (literal.attribute ? literal.attribute.length + 1 : 0); const multilineClasses = new Lines(ctx, lineStartPosition); const singlelineClasses = new Lines(ctx, lineStartPosition); const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); const invalidLineBreaks = isLineBreakStyleLikelyMisconfigured(ctx, literal.raw); const invalidIndentations = isIndentationLikelyMisconfigured(ctx, literal.raw); if (invalidLineBreaks) { warnings.push({ option: "lineBreakStyle", title: "Inconsistent line endings detected", url: `${ctx.docs}#linebreakstyle` }); } if (invalidIndentations) { warnings.push({ option: "indent", title: "Inconsistent indentation detected", url: `${ctx.docs}#indent` }); } const groupedClasses = groupClasses(classes, dissectedClasses); if (literal.openingQuote) { if (literal.multilineQuotes?.includes("`")) { multilineClasses.line.addMeta({ openingQuote: "`" }); } else { multilineClasses.line.addMeta({ openingQuote: literal.openingQuote }); } } if (literal.openingQuote && literal.closingQuote) { singlelineClasses.line.addMeta({ closingQuote: literal.closingQuote, openingQuote: literal.openingQuote }); } leadingTemplateLiteralNewLine: if (literal.isInterpolated && literal.closingBraces) { multilineClasses.line.addMeta({ closingBraces: literal.closingBraces }); // skip newline for sticky classes if (literal.leadingWhitespace === "" && groupedClasses) { break leadingTemplateLiteralNewLine; } // skip if no classes are present if (!groupedClasses) { break leadingTemplateLiteralNewLine; } if (groupSeparator === "emptyLine") { multilineClasses.addLine(); } if (groupSeparator === "emptyLine" || groupSeparator === "newLine" || groupSeparator === "never") { multilineClasses.addLine(); multilineClasses.line.indent(); } } if (groupedClasses) { for (let g = 0; g < groupedClasses.length; g++) { const group = groupedClasses.at(g); const isFirstGroup = g === 0; if (group.classCount === 0) { continue; } if (isFirstGroup && (literal.isInterpolated && !literal.closingBraces || !literal.isInterpolated)) { multilineClasses.addLine(); multilineClasses.line.indent(); } if (!isFirstGroup) { if (groupSeparator === "emptyLine") { multilineClasses.addLine(); } if (groupSeparator === "emptyLine" || groupSeparator === "newLine") { multilineClasses.addLine(); multilineClasses.line.indent(); } } for (let i = 0; i < group.classCount; i++) { const isFirstClass = i === 0; const isLastClass = i === group.classCount - 1; const className = group.at(i); const simulatedLine = multilineClasses.line .clone() .addClass(className) .toString(); // wrap after the first sticky class if (isFirstClass && literal.leadingWhitespace === "" && literal.isInterpolated && literal.closingBraces) { multilineClasses.line.addClass(className); // don't add a new line if the first class is also the last if (isLastClass) { break; } if (groupSeparator === "emptyLine") { multilineClasses.addLine(); } if (groupSeparator === "emptyLine" || groupSeparator === "newLine") { multilineClasses.addLine(); multilineClasses.line.indent(); } continue; } // wrap before the last sticky class if (isLastClass && literal.trailingWhitespace === "" && literal.isInterpolated && literal.openingBraces) { // skip wrapping for the first class of a group if (isFirstClass) { multilineClasses.line.addClass(className); continue; } if (groupSeparator === "emptyLine") { multilineClasses.addLine(); } if (groupSeparator === "emptyLine" || groupSeparator === "newLine") { multilineClasses.addLine(); multilineClasses.line.indent(); } multilineClasses.line.addClass(className); continue; } // wrap if the length exceeds the limits if (simulatedLine.length > printWidth && printWidth !== 0 || multilineClasses.line.classCount >= classesPerLine && classesPerLine !== 0) { // but only if it is not the first class of a group or classes are not grouped if (!isFirstClass || groupSeparator === "never") { multilineClasses.addLine(); multilineClasses.line.indent(); } } multilineClasses.line.addClass(className); singlelineClasses.line.addClass(className); } } } trailingTemplateLiteralNewLine: if (literal.isInterpolated && literal.openingBraces) { // skip newline for sticky classes if (literal.trailingWhitespace === "" && groupedClasses) { multilineClasses.line.addMeta({ openingBraces: literal.openingBraces }); break trailingTemplateLiteralNewLine; } if (groupSeparator === "emptyLine" && groupedClasses) { multilineClasses.addLine(); } if (groupSeparator === "emptyLine" || groupSeparator === "newLine" || groupSeparator === "never") { multilineClasses.addLine(); multilineClasses.line.indent(); } multilineClasses.line.addMeta({ openingBraces: literal.openingBraces }); } if (literal.closingQuote || literal.trailingSemicolon) { multilineClasses.addLine(); multilineClasses.line.indent(lineStartPosition - getIndentation(ctx)); if (literal.multilineQuotes?.includes("`")) { multilineClasses.line.addMeta({ closingQuote: "`" }); } else { multilineClasses.line.addMeta({ closingQuote: literal.closingQuote }); } } // collapse lines if there is no reason for line wrapping or if preferSingleLine is enabled collapse: { // disallow collapsing if the literal contains variants, except preferSingleLine is enabled if (groupedClasses?.length !== 1 && !preferSingleLine) { break collapse; } // disallow collapsing for interpolated literals if (literal.isInterpolated && (literal.openingBraces || literal.closingBraces)) { break collapse; } // disallow collapsing if the original literal was a single line (keeps original whitespace) if (!literal.content.includes(getLineBreaks(ctx))) { break collapse; } // disallow collapsing if the single line contains more classes than the classesPerLine if (singlelineClasses.line.classCount > classesPerLine && classesPerLine !== 0) { break collapse; } // disallow collapsing if the single line including the element and all previous characters is longer than the printWidth if (literalStartPosition + singlelineClasses.line.length > printWidth && printWidth !== 0) { break collapse; } // add leading space for apply collapse if (literal.leadingApply && !literal.leadingApply.endsWith(" ")) { singlelineClasses.line.addMeta({ leadingWhitespace: " " }); } const fixedClasses = singlelineClasses.line.toString(false); if (literal.raw === fixedClasses) { continue; } ctx.report({ data: { notReadable: display(messageStyle, literal.raw), readable: display(messageStyle, fixedClasses) }, fix: fixedClasses, id: "unnecessary", range: literal.range, warnings }); return; } // skip if class string was empty if (multilineClasses.length === 2) { if (!literal.openingBraces && !literal.closingBraces && literal.content.trim() === "") { continue; } } // skip line wrapping if preferSingleLine is enabled and the single line does not exceed the printWidth or classesPerLine if (preferSingleLine && (literalStartPosition + singlelineClasses.line.length <= printWidth && printWidth !== 0 || singlelineClasses.line.classCount <= classesPerLine && classesPerLine !== 0) || printWidth === 0 && classesPerLine === 0) { continue; } // force skip if prettier would wrap the attribute to a new line and then the single line would fit if (strictness === "loose" && literalStartPosition + singlelineClasses.line.length > printWidth && printWidth !== 0 && prettierStartPosition + singlelineClasses.line.length <= printWidth) { continue; } // skip line wrapping if it is not necessary skip: { // disallow skipping if class string contains multiple groups if (groupedClasses && groupedClasses.length > 1) { break skip; } // disallow skipping if the original literal was longer than the printWidth if (literalStartPosition + singlelineClasses.line.length > printWidth && printWidth !== 0 || singlelineClasses.line.classCount > classesPerLine && classesPerLine !== 0) { break skip; } // disallow skipping for interpolated literals if (literal.isInterpolated && (literal.openingBraces || literal.closingBraces)) { break skip; } const openingQuoteLength = literal.openingQuote?.length ?? 0; const closingBracesLength = literal.closingBraces?.length ?? 0; const firstLineLength = multilineClasses .at(1) .toString() .trim() .length + openingQuoteLength + closingBracesLength; // disallow skipping if the first line including the element and all previous characters is longer than the printWidth if (literalStartPosition + firstLineLength > printWidth && printWidth !== 0) { break skip; } // disallow skipping if the first line contains more classes than the classesPerLine if (multilineClasses.at(1).classCount > classesPerLine && classesPerLine !== 0) { break skip; } continue; } const fixedClasses = multilineClasses.toString(); if (literal.raw === fixedClasses) { continue; } ctx.report({ data: { notReadable: display(messageStyle, literal.raw), readable: display(messageStyle, fixedClasses) }, fix: literal.surroundingBraces ? `{${fixedClasses}}` : fixedClasses, id: "missing", range: literal.range, warnings }); } } function getIndentation(ctx) { const { indent } = ctx.options; return indent === "tab" ? 1 : indent ?? 0; } class Lines { constructor(ctx, indentation) { this.lines = []; this.indentation = 0; this.ctx = ctx; this.indentation = indentation; this.addLine(); } at(index) { return index >= 0 ? this.lines[index] : this.lines[this.lines.length + index]; } get line() { return this.currentLine; } get length() { return this.lines.length; } addLine() { const line = new Line(this.ctx, this.indentation); this.lines.push(line); this.currentLine = line; return this; } toString() { const lineBreaks = getLineBreaks(this.ctx); return this.lines.map(line => line.toString()).join(lineBreaks); } } class Line { constructor(ctx, indentation) { this.classes = []; this.meta = {}; this.indentation = 0; this.ctx = ctx; this.indentation = indentation; } indent(start = this.indentation) { const { indent } = this.ctx.options; if (indent === "tab") { this.meta.indentation = "\t".repeat(start); } else { this.meta.indentation = " ".repeat(start); } return this; } get length() { return this.toString().length; } get classCount() { return this.classes.length; } get printWidth() { return this.toString().length; } addMeta(meta) { this.meta = { ...this.meta, ...meta }; return this; } addClass(className) { this.classes.push(className); return this; } clone() { const line = new Line(this.ctx, this.indentation); line.classes = [...this.classes]; line.meta = { ...this.meta }; return line; } toString(indent = true) { return this.join([ indent ? this.meta.indentation : "", this.meta.openingQuote, this.meta.closingBraces, this.meta.leadingWhitespace ?? "", escapeNestedQuotes(this.join(this.classes), this.meta.openingQuote ?? this.meta.closingQuote ?? "`"), this.meta.trailingWhitespace ?? "", this.meta.openingBraces, this.meta.closingQuote ], ""); } join(content, separator = " ") { return content .filter(content => content !== undefined) .join(separator); } } function groupClasses(classes, dissectedClasses) { if (classes.length === 0) { return; } const groups = new Groups(); for (const className of classes) { const isFirstClass = classes.indexOf(className) === 0; const isFirstGroup = groups.length === 1; const lastGroup = groups.at(-1); const lastClassName = lastGroup?.at(-1); if (lastClassName) { const lastDissectedClass = dissectedClasses[lastClassName]; const currentDissectedClass = dissectedClasses[className]; // parse variants manually for custom component classes const lastVariant = lastDissectedClass.variants?.join() ?? lastClassName.match(/^(.*):/)?.[1] ?? ""; const variant = currentDissectedClass.variants?.join() ?? className.match(/^(.*):/)?.[1] ?? ""; if (lastVariant !== variant && !(isFirstClass && isFirstGroup)) { groups.addGroup(); } } groups.group.addClass(className); } return groups; } class Groups { constructor() { this.groups = []; this.addGroup(); } get group() { return this.currentGroup; } at(index) { return this.groups.at(index); } get length() { return this.groups.length; } addGroup() { const group = new Group(); this.currentGroup = group; this.groups.push(this.currentGroup); return this; } } class Group { constructor() { this.classes = []; } get classCount() { return this.classes.length; } at(index) { return this.classes.at(index); } addClass(className) { this.classes.push(className); return this; } } function getLineBreaks(ctx) { const { lineBreakStyle } = ctx.options; return lineBreakStyle === "unix" ? "\n" : "\r\n"; } function isLineBreakStyleLikelyMisconfigured(ctx, original) { const { lineBreakStyle } = ctx.options; const hasWindowsLineBreaks = original.includes("\r\n"); const hasUnixLineBreaks = /(^|[^\r])\n/.test(original); return (hasWindowsLineBreaks && lineBreakStyle === "unix" || hasUnixLineBreaks && lineBreakStyle === "windows"); } function isIndentationLikelyMisconfigured(ctx, original) { const { indent } = ctx.options; const hasSpaceIndentation = /\r?\n +/.test(original); const hasTabIndentation = /\r?\n\t+/.test(original); return (hasSpaceIndentation && indent === "tab" || hasTabIndentation && typeof indent === "number"); } //# sourceMappingURL=enforce-consistent-line-wrapping.js.map