cloudflare/kumo

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
85ee20f555b2a6bdba0cbc54367321e934f8dc47

Branches

Tags

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

Clone

HTTPS

Download ZIP

lint/no-primitive-colors.js

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