/** * Oxlint rule: require-cpk-prefix * * Enforces that Tailwind utility classes in className attributes and * class-helper calls (cn, twMerge, cva, clsx) use the `cpk:` prefix. * Also detects the prefix in the wrong position (after variants instead of before). * * In Tailwind v4 with prefix(cpk), the prefix MUST come before all variants: * cpk:dark:hover:bg-white ✓ (generates CSS) * dark:hover:cpk:bg-white ✗ (generates NO CSS) * * Detection logic reused from scripts/add-cpk-prefix.mjs. */ const PREFIX = "cpk:"; // ── Tailwind utility detection ────────────────────────────────────────────── const SINGLE_WORD_UTILITIES = new Set([ "absolute", "antialiased", "block", "border", "capitalize", "collapse", "container", "contents", "fixed", "flex", "grid", "grow", "hidden", "inline", "inline-block", "inline-flex", "inline-grid", "invisible", "isolate", "italic", "lowercase", "ordinal", "outline", "overflow", "overline", "relative", "resize", "ring", "rounded", "shadow", "shrink", "static", "sticky", "table", "truncate", "underline", "uppercase", "visible", "prose", ]); const JS_VALUE_WORDS = new Set([ "absolute", "static", "relative", "fixed", "sticky", "contents", "none", "auto", "inherit", "initial", "unset", "revert", "block", "inline", "flex", "grid", "hidden", "smooth", "instant", "nearest", "button", "submit", "reset", "input", "transcribe", "processing", "recording", "idle", "text", "password", "email", "number", "tel", "url", "search", "top", "bottom", "left", "right", "start", "end", "compact", "expanded", "before", "after", "open", "closed", "default", "destructive", "outline", "secondary", "ghost", "link", ]); const VARIANT_RE = /^(?:dark|hover|focus|focus-visible|focus-within|active|disabled|visited|checked|required|invalid|first|last|odd|even|only|empty|enabled|read-only|placeholder-shown|autofill|default|indeterminate|open|closed|group-hover|group-focus|peer-hover|peer-focus|peer-checked|placeholder|before|after|selection|marker|first-line|first-letter|file|sm|md|lg|xl|2xl|portrait|landscape|motion-safe|motion-reduce|contrast-more|contrast-less|forced-colors|print|ltr|rtl|aria-invalid|aria-checked|aria-disabled|aria-expanded|aria-hidden|aria-pressed|aria-readonly|aria-required|aria-selected|has-\[.+?\]|not-\[.+?\]|group-data-\[.+?\]|supports-\[.+?\]|min-\[.+?\]|max-\[.+?\]|data-\[.+?\]|aria-\[.+?\]|\[.+?\]):/; function stripVariants(token) { let base = token; let iterations = 0; while (VARIANT_RE.test(base) && iterations < 20) { base = base.replace(VARIANT_RE, ""); iterations++; } return base; } function isBaseUtility(base) { if (!base) return false; let b = base; if (b.startsWith("-") || b.startsWith("!")) b = b.slice(1); const slashIdx = b.indexOf("/"); let bNoOpacity = b; if (slashIdx > 0 && !b.includes("[")) { bNoOpacity = b.slice(0, slashIdx); } if (SINGLE_WORD_UTILITIES.has(bNoOpacity)) return true; const prefixes = [ "bg-", "text-", "font-", "p-", "px-", "py-", "pt-", "pb-", "pl-", "pr-", "m-", "mx-", "my-", "mt-", "mb-", "ml-", "mr-", "w-", "h-", "min-w-", "max-w-", "min-h-", "max-h-", "size-", "flex-", "grid-", "col-", "row-", "auto-cols-", "auto-rows-", "gap-", "gap-x-", "gap-y-", "space-x-", "space-y-", "items-", "justify-", "self-", "content-", "place-", "border-", "rounded-", "ring-", "outline-", "divide-", "shadow-", "z-", "inset-", "inset-x-", "inset-y-", "top-", "right-", "bottom-", "left-", "start-", "end-", "leading-", "tracking-", "whitespace-", "break-", "indent-", "align-", "decoration-", "underline-offset-", "opacity-", "overflow-", "object-", "float-", "clear-", "transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-", "translate-", "skew-", "origin-", "cursor-", "pointer-events-", "select-", "touch-", "scroll-", "snap-", "accent-", "caret-", "will-change-", "contain-", "fill-", "stroke-", "aspect-", "columns-", "from-", "via-", "to-", "gradient-", "backdrop-", "blur-", "brightness-", "contrast-", "drop-shadow-", "grayscale-", "hue-rotate-", "invert-", "saturate-", "sepia-", "ring-offset-", "list-", "order-", "basis-", "grow-", "shrink-", "sr-", "appearance-", "transform-", ]; for (const p of prefixes) { if (bNoOpacity.startsWith(p)) return true; } if (b.startsWith("[") && b.endsWith("]")) return true; if (/\[.+\]/.test(b)) return true; if (/\(/.test(b) && /^[a-z]/.test(b)) return true; if ( /^(line-through|no-underline|normal-case|not-italic|subpixel-antialiased|table-auto|table-fixed|border-collapse|border-separate|sr-only|not-sr-only|break-words|break-all|break-normal|overflow-auto|overflow-hidden|overflow-visible|overflow-scroll|overflow-x-auto|overflow-x-hidden|overflow-y-auto|overflow-y-hidden|overflow-y-scroll|inline-block|inline-flex|inline-grid|flow-root|list-item|outline-hidden|outline-none|bg-clip-padding|bg-gradient-to-t|bg-gradient-to-b|bg-gradient-to-l|bg-gradient-to-r|not-prose|transform-gpu)$/.test( bNoOpacity, ) ) return true; return false; } function looksLikeTailwindUtility(token) { if (!token) return false; if (token.startsWith(PREFIX)) return false; if (token.startsWith("@")) return false; if (token.startsWith("data-") && !token.includes(":")) return false; let base = stripVariants(token); if (!base) return false; // Already prefixed somewhere after stripping known variants. // Uses includes() instead of startsWith() to handle unknown variants // that aren't in VARIANT_RE (e.g. *: child variant, &: nesting). if (base.includes(PREFIX)) return false; return isBaseUtility(base); } // ── Token prefixing ───────────────────────────────────────────────────────── // In Tailwind v4 with prefix(cpk), the prefix MUST come before all variants: // cpk:dark:hover:bg-white ✓ // dark:hover:cpk:bg-white ✗ (generates no CSS) function prefixToken(token) { if (!looksLikeTailwindUtility(token)) return token; return PREFIX + token; } // Detect tokens where cpk: is placed after variant(s) instead of before them. // e.g. dark:cpk:bg-white, hover:cpk:text-blue, dark:hover:cpk:bg-red const WRONG_PREFIX_RE = /^((?:[a-z][-a-z0-9]*(?:\[.*?\])?:|\[.*?\]:)+)cpk:(.+)$/; function hasWrongPrefixPosition(token) { return WRONG_PREFIX_RE.test(token); } function fixPrefixPosition(token) { return token.replace(WRONG_PREFIX_RE, "cpk:$1$2"); } // ── Oxlint rule ───────────────────────────────────────────────────────────── const rule = { meta: { type: "suggestion", docs: { description: "Enforce cpk: prefix on Tailwind utility classes in className attributes", }, fixable: "code", schema: [], messages: { missingPrefix: "'{{token}}' is missing the 'cpk:' prefix. Use '{{fixed}}' instead. See oxlint-rules/README.md for why.", wrongPrefixPosition: "'{{token}}' has 'cpk:' in the wrong position. The prefix must come BEFORE variants. Use '{{fixed}}' instead. See oxlint-rules/README.md for why.", }, }, create(context) { const sourceCode = context.sourceCode || context.getSourceCode(); const CLASS_HELPERS = new Set(["cn", "twMerge", "cva", "clsx"]); const checked = new WeakSet(); // ── String-literal checker ──────────────────────────────────────────── function checkStringLiteral(node) { if (checked.has(node)) return; checked.add(node); const value = node.value; if (typeof value !== "string" || !value.trim()) return; // Skip single-word strings that look like JS values, not class names const trimmed = value.trim(); if (!trimmed.includes(" ") && !trimmed.includes("\t")) { if (JS_VALUE_WORDS.has(trimmed)) return; if ( !trimmed.includes("-") && !trimmed.includes(":") && !trimmed.includes("[") ) return; } // Work with the raw source to get accurate positions const src = sourceCode.getText(node); const inner = src.slice(1, -1); // strip quotes const innerStart = node.range[0] + 1; const regex = /\S+/g; let match; while ((match = regex.exec(inner)) !== null) { const token = match[0]; const rangeStart = innerStart + match.index; const rangeEnd = rangeStart + token.length; if (hasWrongPrefixPosition(token)) { const fixed = fixPrefixPosition(token); context.report({ node, messageId: "wrongPrefixPosition", data: { token, fixed }, fix(fixer) { return fixer.replaceTextRange([rangeStart, rangeEnd], fixed); }, }); } else if (looksLikeTailwindUtility(token)) { const fixed = prefixToken(token); context.report({ node, messageId: "missingPrefix", data: { token, fixed }, fix(fixer) { return fixer.replaceTextRange([rangeStart, rangeEnd], fixed); }, }); } } } // ── Template-literal quasi checker ──────────────────────────────────── function checkQuasi(quasi) { if (checked.has(quasi)) return; checked.add(quasi); const raw = quasi.value.raw; if (!raw || !raw.trim()) return; // Find where the raw content starts in the source. // TemplateElement ranges include delimiters (` or ${ or }). const srcSlice = sourceCode.text.slice(quasi.range[0], quasi.range[1]); const rawIdx = srcSlice.indexOf(raw); const contentStart = quasi.range[0] + (rawIdx >= 0 ? rawIdx : 1); const regex = /\S+/g; let match; while ((match = regex.exec(raw)) !== null) { const token = match[0]; const rangeStart = contentStart + match.index; const rangeEnd = rangeStart + token.length; if (hasWrongPrefixPosition(token)) { const fixed = fixPrefixPosition(token); context.report({ node: quasi, messageId: "wrongPrefixPosition", data: { token, fixed }, fix(fixer) { return fixer.replaceTextRange([rangeStart, rangeEnd], fixed); }, }); } else if (looksLikeTailwindUtility(token)) { const fixed = prefixToken(token); context.report({ node: quasi, messageId: "missingPrefix", data: { token, fixed }, fix(fixer) { return fixer.replaceTextRange([rangeStart, rangeEnd], fixed); }, }); } } } // ── Recursive expression walker ─────────────────────────────────────── function checkExpression(node) { if (!node) return; switch (node.type) { case "Literal": case "StringLiteral": // babel parser if (typeof node.value === "string") checkStringLiteral(node); break; case "JSXExpressionContainer": checkExpression(node.expression); break; case "TemplateLiteral": for (const quasi of node.quasis) checkQuasi(quasi); for (const expr of node.expressions) checkExpression(expr); break; case "ConditionalExpression": checkExpression(node.consequent); checkExpression(node.alternate); break; case "LogicalExpression": // e.g. isActive && "bg-blue-500" checkExpression(node.left); checkExpression(node.right); break; case "CallExpression": if (isClassHelper(node.callee)) { for (const arg of node.arguments) checkExpression(arg); } break; case "ArrayExpression": for (const el of node.elements) { if (el) checkExpression(el); } break; case "ObjectExpression": // For cva variant objects: { variant: { default: "...", ... } } for (const prop of node.properties) { if (prop.value) checkExpression(prop.value); } break; case "SpreadElement": break; default: break; } } function isClassHelper(callee) { if (!callee) return false; if (callee.type === "Identifier") { return CLASS_HELPERS.has(callee.name); } // Handle e.g. module.cn() if (callee.type === "MemberExpression" && callee.property) { const name = callee.property.type === "Identifier" ? callee.property.name : callee.property.value; return CLASS_HELPERS.has(name); } return false; } // ── Visitors ────────────────────────────────────────────────────────── return { JSXAttribute(node) { if ( node.name && node.name.type === "JSXIdentifier" && node.name.name === "className" && node.value ) { checkExpression(node.value); } }, CallExpression(node) { if (isClassHelper(node.callee)) { for (const arg of node.arguments) { checkExpression(arg); } } }, }; }, }; export default rule;