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-primitive-colors.js

443lines · modecode

1import { defineRule } from "oxlint";
2import { readFileSync } from "node:fs";
3import { dirname, resolve } from "node:path";
4import { fileURLToPath } from "node:url";
5
6const __dirname = dirname(fileURLToPath(import.meta.url));
7
8const RULE_NAME = "no-primitive-colors";
9const INVALID_TOKEN_RULE = "invalid-color-token";
10
11// We want to enforce use of Kumo semantic color tokens `--color-kumo-*`.
12// Any Tailwind color utility (e.g. `bg-blue-500`) or legacy semantic
13// utility (e.g. `bg-kumo-ring`, `text-kumo-default`) in class strings should be
14// replaced by semantic tokens / component APIs.
15
16// Matches Tailwind-like color utilities in class strings.
17// Example matches: bg-blue-500, text-surface, border-primary/50, hover:bg-sky-100
18
19export const TOKEN_RE =
20 /(?:^|[^a-zA-Z0-9-])(((?:[a-z-]+:)*)?(?:bg|border|text|ring(?:-offset)?|fill|stroke|placeholder|caret|accent|decoration|divide|outline|from|via|to)-([a-z][a-z0-9-]*)(?:-\d{2,3})?(?:\/[0-9]{1,3})?)/gim;
21
22export const TAILWIND_COLOR_FAMILIES = new Set([
23 "red",
24 "orange",
25 "amber",
26 "yellow",
27 "lime",
28 "green",
29 "emerald",
30 "teal",
31 "cyan",
32 "sky",
33 "blue",
34 "indigo",
35 "violet",
36 "purple",
37 "fuchsia",
38 "pink",
39 "slate",
40 "gray",
41 "zinc",
42 "neutral",
43 "stone",
44 // Note: "black", "white", and "transparent" are intentionally excluded
45 // so utilities like bg-white, text-black, ring-transparent are allowed.
46]);
47
48// Tailwind's built-in color names that are always valid
49const BUILTIN_COLORS = new Set(["white", "black"]);
50
51// Non-color utilities that look like color tokens but aren't
52const NON_COLOR_UTILITIES = new Set([
53 // Text utilities (not colors)
54 "xs",
55 "sm",
56 "base",
57 "lg",
58 "xl",
59 "2xl",
60 "3xl",
61 "4xl",
62 "left",
63 "center",
64 "right",
65 "justify",
66 "wrap",
67 "nowrap",
68 "balance",
69 "pretty",
70 "ellipsis",
71 "clip",
72 // Special color values (valid for all prefixes)
73 "transparent",
74 "current",
75 "inherit",
76 "none",
77 // Border utilities (not colors)
78 "0",
79 "2",
80 "4",
81 "8",
82 "t",
83 "r",
84 "b",
85 "l",
86 "x",
87 "y",
88 "solid",
89 "dashed",
90 "dotted",
91 "double",
92 "hidden",
93 "collapse",
94 "separate",
95 // Ring utilities (not colors)
96 "1",
97 "inset",
98 // Shadow utilities (not colors)
99 "inner",
100 // Divide utilities (not colors)
101]);
102
103// Patterns that look like color tokens but are actually other utilities
104// These use regex patterns for more flexible matching
105const NON_COLOR_PATTERNS = [
106 // Gradient directions: bg-linear-to-r, bg-linear-to-bl, etc.
107 /^linear-to-[trbl]{1,2}$/,
108 // Border directional + size: border-l-2, border-t-4, etc.
109 /^[trblxy]-\d+$/,
110 // Outline/ring offset utilities: outline-offset-3, ring-offset-2, etc.
111 /^offset-\d+$/,
112 // Generic numeric utilities
113 /^\d+$/,
114 // Background clip utilities: bg-clip-padding, bg-clip-content, etc.
115 /^clip-.+$/,
116];
117
118// Parse theme CSS files to extract valid semantic color tokens.
119// This ensures the allowlist stays in sync with the theme files.
120function parseKumoSemanticColors() {
121 const themeFiles = [
122 resolve(__dirname, "../packages/kumo/src/styles/theme-kumo.css"),
123 resolve(__dirname, "../packages/kumo/src/styles/theme-fedramp.css"),
124 ];
125
126 const colorTokens = new Set();
127 const textColorTokens = new Set();
128
129 for (const themePath of themeFiles) {
130 try {
131 const css = readFileSync(themePath, "utf-8");
132
133 // Match --color-<name> custom properties (excluding --text-color-*)
134 const colorPropRe = /--color-([a-z][a-z0-9-]*)(?=\s*:)/gi;
135 let match;
136 while ((match = colorPropRe.exec(css))) {
137 const name = match[1];
138 // Skip Tailwind primitive color definitions (e.g. red-650, blue-400, neutral-50)
139 // These have 2-3 digit shade values. Single digit suffixes like green-2 are valid semantic tokens.
140 if (/^[a-z]+-\d{2,3}$/.test(name)) continue;
141 colorTokens.add(name);
142 }
143
144 // Match --text-color-<name> custom properties separately
145 const textColorPropRe = /--text-color-([a-z][a-z0-9-]*)(?=\s*:)/gi;
146 while ((match = textColorPropRe.exec(css))) {
147 textColorTokens.add(match[1]);
148 }
149 } catch {
150 // File doesn't exist, skip
151 }
152 }
153
154 // Add built-in colors to both sets
155 for (const color of BUILTIN_COLORS) {
156 colorTokens.add(color);
157 textColorTokens.add(color);
158 }
159
160 return { colorTokens, textColorTokens };
161}
162
163// Valid Kumo semantic color tokens derived from theme CSS files.
164// colorTokens: map to --color-* (used by bg-*, border-*, ring-*, etc.)
165// textColorTokens: map to --text-color-* (used by text-*)
166const {
167 colorTokens: VALID_COLOR_TOKENS,
168 textColorTokens: VALID_TEXT_COLOR_TOKENS,
169} = parseKumoSemanticColors();
170
171// Legacy export for backwards compatibility
172export const VALID_KUMO_SEMANTIC_COLORS = new Set([
173 ...VALID_COLOR_TOKENS,
174 ...VALID_TEXT_COLOR_TOKENS,
175]);
176
177function extractStrings(node) {
178 if (!node) return [];
179 const out = [];
180
181 switch (node.type) {
182 case "Literal": {
183 if (typeof node.value === "string") out.push(node.value);
184 break;
185 }
186 case "TemplateLiteral": {
187 for (const q of node.quasis) {
188 if (typeof q.value.cooked === "string") out.push(q.value.cooked);
189 }
190 break;
191 }
192 case "BinaryExpression": {
193 if (node.operator === "+") {
194 out.push(...extractStrings(node.left));
195 out.push(...extractStrings(node.right));
196 }
197 break;
198 }
199 case "ArrayExpression": {
200 for (const el of node.elements) {
201 if (el) {
202 out.push(...extractStrings(el));
203 }
204 }
205 break;
206 }
207 case "ObjectExpression": {
208 for (const prop of node.properties) {
209 if (prop.type === "Property") {
210 out.push(...extractStrings(prop.key));
211 out.push(...extractStrings(prop.value));
212 }
213 }
214 break;
215 }
216 case "CallExpression": {
217 for (const arg of node.arguments) {
218 if (arg.type === "SpreadElement") continue;
219 out.push(...extractStrings(arg));
220 }
221 break;
222 }
223 case "ConditionalExpression": {
224 out.push(...extractStrings(node.consequent));
225 out.push(...extractStrings(node.alternate));
226 out.push(...extractStrings(node.test));
227 break;
228 }
229 case "UnaryExpression": {
230 out.push(...extractStrings(node.argument));
231 break;
232 }
233 case "LogicalExpression": {
234 out.push(...extractStrings(node.left));
235 out.push(...extractStrings(node.right));
236 break;
237 }
238 case "JSXText": {
239 out.push(node.value);
240 break;
241 }
242 case "JSXExpressionContainer": {
243 out.push(...extractStrings(node.expression));
244 break;
245 }
246 }
247
248 return out;
249}
250
251// Prefixes that use --color-* tokens
252const COLOR_PREFIXES = new Set([
253 "bg",
254 "border",
255 "ring",
256 "ring-offset",
257 "fill",
258 "stroke",
259 "placeholder",
260 "caret",
261 "accent",
262 "decoration",
263 "divide",
264 "outline",
265 "from",
266 "via",
267 "to",
268]);
269
270// Prefixes that use --text-color-* tokens
271const TEXT_COLOR_PREFIXES = new Set(["text"]);
272
273/**
274 * Check if a string contains primitive Tailwind colors.
275 * Returns the first invalid token found, or null if all are valid.
276 */
277function findPrimitiveColor(str) {
278 if (!str) return null;
279
280 TOKEN_RE.lastIndex = 0;
281 let match;
282 while ((match = TOKEN_RE.exec(str))) {
283 const fullToken = match[1];
284 const colorFamily = match[3];
285
286 if (!fullToken || !colorFamily) continue;
287
288 // Skip valid Kumo semantic color tokens (e.g. bg-surface, text-secondary,
289 // border-kumo-fill). These are backed by theme-kumo.css custom properties.
290 if (VALID_KUMO_SEMANTIC_COLORS.has(colorFamily)) continue;
291
292 // Flag kumo- prefixed classes (e.g. text-kumo-surface).
293 // These are invalid; use semantic tokens directly (e.g. text-kumo-default).
294 if (colorFamily.startsWith("kumo-"))
295 return { type: "primitive", token: fullToken };
296
297 // Flag Tailwind primitive color families (e.g. blue, slate, red).
298 // Tailwind utilities often use a numeric shade suffix (e.g. neutral-500).
299 // The regex captures the color name which may include a trailing numeric
300 // segment (e.g. "green-2" from text-green-2). Strip trailing -N segments
301 // to get the base family name for checking against Tailwind primitives.
302 const primitiveFamily = colorFamily.replace(/-\d+$/, "");
303
304 // Only flag if it's a Tailwind primitive AND not a valid Kumo semantic token.
305 // This handles cases like "green-2" where "green" is a Tailwind primitive
306 // but "green-2" is a valid Kumo semantic token.
307 if (
308 TAILWIND_COLOR_FAMILIES.has(primitiveFamily) &&
309 !VALID_KUMO_SEMANTIC_COLORS.has(colorFamily)
310 )
311 return { type: "primitive", token: fullToken };
312 }
313
314 return null;
315}
316
317/**
318 * Check if a token name matches any non-color pattern
319 */
320function isNonColorUtility(tokenName) {
321 if (NON_COLOR_UTILITIES.has(tokenName)) return true;
322 return NON_COLOR_PATTERNS.some((pattern) => pattern.test(tokenName));
323}
324
325/**
326 * Check if a string contains invalid/unknown color tokens.
327 * Returns the first invalid token found, or null if all are valid.
328 */
329function findInvalidToken(str) {
330 if (!str) return null;
331
332 TOKEN_RE.lastIndex = 0;
333 let match;
334 while ((match = TOKEN_RE.exec(str))) {
335 const fullToken = match[1];
336 const colorFamily = match[3];
337
338 if (!fullToken || !colorFamily) continue;
339
340 // Skip non-color utilities (both exact matches and patterns)
341 if (isNonColorUtility(colorFamily)) continue;
342
343 // Skip arbitrary values (e.g., bg-[#fff])
344 if (colorFamily.startsWith("[")) continue;
345
346 // Determine the prefix to check which token set to validate against
347 const prefixMatch = fullToken.match(
348 /^(?:[a-z-]+:)*(bg|border|text|ring(?:-offset)?|fill|stroke|placeholder|caret|accent|decoration|divide|outline|from|via|to)-/i,
349 );
350 if (!prefixMatch) continue;
351
352 const prefix = prefixMatch[1].toLowerCase();
353
354 // Remove opacity modifier for token lookup (e.g., "surface/50" -> "surface")
355 const tokenName = colorFamily.replace(/\/\d+$/, "");
356
357 // Skip if the token name (without opacity) is a non-color utility
358 if (isNonColorUtility(tokenName)) continue;
359
360 // Check if it's a text-* utility
361 if (TEXT_COLOR_PREFIXES.has(prefix)) {
362 // text-* maps to --text-color-* tokens
363 if (
364 !VALID_TEXT_COLOR_TOKENS.has(tokenName) &&
365 !BUILTIN_COLORS.has(tokenName)
366 ) {
367 // Not a Tailwind primitive (already caught by findPrimitiveColor)
368 const primitiveFamily = tokenName.replace(/-\d+$/, "");
369 if (!TAILWIND_COLOR_FAMILIES.has(primitiveFamily)) {
370 return { type: "invalid", token: fullToken, tokenName };
371 }
372 }
373 } else if (COLOR_PREFIXES.has(prefix)) {
374 // Other prefixes map to --color-* tokens
375 if (
376 !VALID_COLOR_TOKENS.has(tokenName) &&
377 !BUILTIN_COLORS.has(tokenName)
378 ) {
379 // Not a Tailwind primitive (already caught by findPrimitiveColor)
380 const primitiveFamily = tokenName.replace(/-\d+$/, "");
381 if (!TAILWIND_COLOR_FAMILIES.has(primitiveFamily)) {
382 return { type: "invalid", token: fullToken, tokenName };
383 }
384 }
385 }
386 }
387
388 return null;
389}
390
391export const noPrimitiveColorsRule = defineRule({
392 meta: {
393 type: "problem",
394 docs: {
395 description:
396 "Disallow Tailwind primitive colors and invalid/unknown semantic color tokens.",
397 },
398 messages: {
399 [RULE_NAME]:
400 "Avoid Tailwind color utilities (e.g. `bg-blue-500`, `border-red-500`). Use Kumo semantic tokens instead.",
401 [INVALID_TOKEN_RULE]:
402 "Invalid color token '{{token}}'. Token '{{tokenName}}' is not defined in theme-kumo.css or theme-fedramp.css.",
403 },
404 schema: [],
405 },
406 defaultOptions: [],
407 createOnce(context) {
408 function reportColorIssues(node, collected) {
409 for (const s of collected) {
410 // First check for primitive Tailwind colors
411 const primitive = findPrimitiveColor(s);
412 if (primitive) {
413 context.report({ node, messageId: RULE_NAME });
414 return;
415 }
416
417 // Then check for invalid/unknown semantic tokens
418 const invalid = findInvalidToken(s);
419 if (invalid) {
420 context.report({
421 node,
422 messageId: INVALID_TOKEN_RULE,
423 data: { token: invalid.token, tokenName: invalid.tokenName },
424 });
425 return;
426 }
427 }
428 }
429
430 return {
431 JSXAttribute(node) {
432 const name =
433 node.name.type === "JSXIdentifier" ? node.name.name : undefined;
434 if (name !== "className" && name !== "class") return;
435
436 if (node.value) {
437 const strings = extractStrings(node.value);
438 reportColorIssues(node, strings);
439 }
440 },
441 };
442 },
443});
444