cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
9fbf3a830bde68cc1da790676dc967d1f731fe04

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

lint/no-tailwind-dark-variant.js

151lines · modepreview

import { defineRule } from "oxlint";

const RULE_NAME = "no-tailwind-dark-variant";

function extractStrings(node) {
  if (!node) return [];
  const out = [];

  switch (node.type) {
    case "Literal": {
      if (typeof node.value === "string") out.push(node.value);
      break;
    }
    case "TemplateLiteral": {
      for (const q of node.quasis) {
        if (typeof q.value.cooked === "string") out.push(q.value.cooked);
      }
      break;
    }
    case "BinaryExpression": {
      if (node.operator === "+") {
        out.push(...extractStrings(node.left));
        out.push(...extractStrings(node.right));
      }
      break;
    }
    case "ArrayExpression": {
      for (const el of node.elements) {
        if (el) {
          out.push(...extractStrings(el));
        }
      }
      break;
    }
    case "ObjectExpression": {
      for (const prop of node.properties) {
        if (prop.type === "Property") {
          out.push(...extractStrings(prop.key));
          out.push(...extractStrings(prop.value));
        }
      }
      break;
    }
    case "CallExpression": {
      for (const arg of node.arguments) {
        if (arg.type === "SpreadElement") continue;
        out.push(...extractStrings(arg));
      }
      break;
    }
    case "ConditionalExpression": {
      out.push(...extractStrings(node.consequent));
      out.push(...extractStrings(node.alternate));
      out.push(...extractStrings(node.test));
      break;
    }
    case "UnaryExpression": {
      out.push(...extractStrings(node.argument));
      break;
    }
    case "LogicalExpression": {
      out.push(...extractStrings(node.left));
      out.push(...extractStrings(node.right));
      break;
    }
    case "JSXText": {
      out.push(node.value);
      break;
    }
    case "JSXExpressionContainer": {
      out.push(...extractStrings(node.expression));
      break;
    }
  }

  return out;
}

function hasDarkVariant(str) {
  // Match dark: followed by a Tailwind utility pattern (e.g., dark:bg-blue-500, dark:text-white)
  // This avoids false positives for object keys like { dark: "vesper" } or { light: ..., dark: ... }
  return /\bdark:[a-z]+[-\w]*/.test(str);
}

function isInsideJsxAttribute(node) {
  let current = node.parent;
  while (current) {
    if (current.type === "JSXAttribute") return true;
    current = current.parent;
  }
  return false;
}

export const noTailwindDarkVariantRule = defineRule({
  meta: {
    type: "problem",
    docs: {
      description: "Disallow Tailwind dark: variant usage in class names",
    },
    messages: {
      [RULE_NAME]:
        "Avoid using Tailwind's dark: variant. Use the design system token or component API for dark mode handling.",
    },
    schema: [],
  },
  defaultOptions: [],
  createOnce(context) {
    function reportIfDark(node, collected) {
      for (const s of collected) {
        if (hasDarkVariant(s)) {
          context.report({ node, messageId: RULE_NAME });
          return;
        }
      }
    }

    return {
      JSXAttribute(node) {
        const name =
          node.name.type === "JSXIdentifier" ? node.name.name : undefined;
        if (name !== "className" && name !== "class") return;

        if (node.value) {
          const strings = extractStrings(node.value);
          reportIfDark(node, strings);
        }
      },
      Literal(node) {
        if (
          typeof node.value !== "string" ||
          !hasDarkVariant(node.value) ||
          isInsideJsxAttribute(node)
        ) {
          return;
        }

        context.report({ node, messageId: RULE_NAME });
      },
      TemplateLiteral(node) {
        if (isInsideJsxAttribute(node)) {
          return;
        }

        const strings = extractStrings(node);
        if (strings.some(hasDarkVariant)) {
          context.report({ node, messageId: RULE_NAME });
        }
      },
    };
  },
});