@almadar/eslint-plugin 2.14.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +7 -0
- package/package.json +1 -1
- package/rules/no-hardcoded-colors.js +154 -0
package/index.js
CHANGED
|
@@ -30,6 +30,9 @@ const organismRequireEntityRow = require("./rules/organism-require-entity-row");
|
|
|
30
30
|
// Atom/molecule rules
|
|
31
31
|
const atomMoleculeNoEntityProp = require("./rules/atom-molecule-no-entity-prop");
|
|
32
32
|
|
|
33
|
+
// Theme rules
|
|
34
|
+
const noHardcodedColors = require("./rules/no-hardcoded-colors");
|
|
35
|
+
|
|
33
36
|
// Behavior rules
|
|
34
37
|
const behaviorNoOrganisms = require("./rules/behavior-no-organisms");
|
|
35
38
|
|
|
@@ -89,6 +92,9 @@ const plugin = {
|
|
|
89
92
|
// Atom/molecule rules
|
|
90
93
|
"atom-molecule-no-entity-prop": atomMoleculeNoEntityProp,
|
|
91
94
|
|
|
95
|
+
// Theme rules
|
|
96
|
+
"no-hardcoded-colors": noHardcodedColors,
|
|
97
|
+
|
|
92
98
|
// Behavior rules
|
|
93
99
|
"behavior-no-organisms": behaviorNoOrganisms,
|
|
94
100
|
|
|
@@ -136,6 +142,7 @@ plugin.configs.recommended = {
|
|
|
136
142
|
"almadar/no-import-generated": "error",
|
|
137
143
|
"almadar/no-use-navigate": "error",
|
|
138
144
|
"almadar/atom-molecule-no-entity-prop": "error",
|
|
145
|
+
"almadar/no-hardcoded-colors": "error",
|
|
139
146
|
|
|
140
147
|
// Warn-level — conventions that should be followed
|
|
141
148
|
"almadar/require-event-bus": "warn",
|
package/package.json
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Disallow hardcoded palette colors in Tailwind class strings.
|
|
5
|
+
*
|
|
6
|
+
* @almadar/ui components must read color from theme tokens
|
|
7
|
+
* (text-foreground, bg-card, border-border, bg-primary, text-muted-foreground,
|
|
8
|
+
* bg-error, ...) so every theme — light or dark — stays readable. Hardcoded
|
|
9
|
+
* utilities (text-white, bg-gray-800, border-indigo-300, text-[#1a2b3c])
|
|
10
|
+
* ignore the active theme and are the root cause of dark-on-dark /
|
|
11
|
+
* light-on-light text, including on hover states.
|
|
12
|
+
*
|
|
13
|
+
* Arbitrary values that reference a CSS variable (bg-[var(--color-card)])
|
|
14
|
+
* are token usage and allowed. Intentional theme-independent paint (e.g.
|
|
15
|
+
* white caption over a media scrim) needs an explicit eslint-disable.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const PALETTE_NAMES =
|
|
19
|
+
"slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|" +
|
|
20
|
+
"teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
|
|
21
|
+
|
|
22
|
+
const COLOR_UTILITIES =
|
|
23
|
+
"text|bg|border|ring|fill|stroke|from|via|to|divide|outline|shadow|" +
|
|
24
|
+
"decoration|placeholder|caret|accent";
|
|
25
|
+
|
|
26
|
+
const PALETTE_RE = new RegExp(
|
|
27
|
+
`^(?:${COLOR_UTILITIES})-(?:${PALETTE_NAMES})-\\d{2,3}(?:\\/[\\d.]+)?$`
|
|
28
|
+
);
|
|
29
|
+
const WHITE_BLACK_RE = new RegExp(
|
|
30
|
+
`^(?:${COLOR_UTILITIES})-(?:white|black)(?:\\/[\\d.]+)?$`
|
|
31
|
+
);
|
|
32
|
+
const ARBITRARY_COLOR_RE = /\[(?:#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\()/;
|
|
33
|
+
|
|
34
|
+
const SUGGESTIONS = {
|
|
35
|
+
"text-white": "text-primary-foreground (or the matching *-foreground token)",
|
|
36
|
+
"text-black": "text-foreground",
|
|
37
|
+
"bg-white": "bg-card",
|
|
38
|
+
"bg-black": "bg-background",
|
|
39
|
+
"text-gray-400": "text-muted-foreground",
|
|
40
|
+
"text-gray-500": "text-muted-foreground",
|
|
41
|
+
"text-gray-600": "text-muted-foreground",
|
|
42
|
+
"text-gray-700": "text-foreground",
|
|
43
|
+
"text-gray-900": "text-foreground",
|
|
44
|
+
"bg-gray-50": "bg-muted",
|
|
45
|
+
"bg-gray-100": "bg-muted",
|
|
46
|
+
"bg-gray-800": "bg-card",
|
|
47
|
+
"bg-gray-900": "bg-background",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function findHardcodedToken(classString) {
|
|
51
|
+
for (const rawToken of classString.split(/\s+/)) {
|
|
52
|
+
if (!rawToken) continue;
|
|
53
|
+
// Strip variant prefixes (hover:, dark:, group-hover:, sm:, ...)
|
|
54
|
+
const token = rawToken.slice(rawToken.lastIndexOf(":") + 1);
|
|
55
|
+
// Strip important modifier
|
|
56
|
+
const bare = token.startsWith("!") ? token.slice(1) : token;
|
|
57
|
+
if (PALETTE_RE.test(bare) || WHITE_BLACK_RE.test(bare)) {
|
|
58
|
+
return { token: rawToken, bare };
|
|
59
|
+
}
|
|
60
|
+
if (ARBITRARY_COLOR_RE.test(bare)) {
|
|
61
|
+
return { token: rawToken, bare };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkClassString(context, node, classString) {
|
|
68
|
+
const hit = findHardcodedToken(classString);
|
|
69
|
+
if (!hit) return;
|
|
70
|
+
const suggestion = SUGGESTIONS[hit.bare];
|
|
71
|
+
context.report({
|
|
72
|
+
node,
|
|
73
|
+
messageId: suggestion ? "hardcodedWithSuggestion" : "hardcoded",
|
|
74
|
+
data: { token: hit.token, suggestion: suggestion || "" },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isClassNameAttribute(node) {
|
|
79
|
+
return (
|
|
80
|
+
node.type === "JSXAttribute" &&
|
|
81
|
+
node.name.type === "JSXIdentifier" &&
|
|
82
|
+
(node.name.name === "className" || node.name.name === "class")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const CLASS_HELPERS = new Set(["cn", "clsx", "twMerge", "cva"]);
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
meta: {
|
|
90
|
+
type: "problem",
|
|
91
|
+
docs: {
|
|
92
|
+
description:
|
|
93
|
+
"Disallow hardcoded Tailwind palette colors — use theme tokens (text-foreground, bg-card, ...) so every theme stays readable",
|
|
94
|
+
category: "Almadar Architecture",
|
|
95
|
+
},
|
|
96
|
+
messages: {
|
|
97
|
+
hardcoded:
|
|
98
|
+
"Hardcoded color '{{token}}' ignores the active theme (breaks light/dark contrast). Use a theme token instead (text-foreground, text-muted-foreground, bg-card, bg-primary, text-primary-foreground, border-border, ...).",
|
|
99
|
+
hardcodedWithSuggestion:
|
|
100
|
+
"Hardcoded color '{{token}}' ignores the active theme (breaks light/dark contrast). Use {{suggestion}} instead.",
|
|
101
|
+
},
|
|
102
|
+
schema: [],
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
create(context) {
|
|
106
|
+
function inspectString(node, value) {
|
|
107
|
+
if (typeof value === "string") checkClassString(context, node, value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function walk(node) {
|
|
111
|
+
if (!node || typeof node !== "object") return;
|
|
112
|
+
if (node.type === "Literal") {
|
|
113
|
+
inspectString(node, node.value);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (node.type === "TemplateLiteral") {
|
|
117
|
+
for (const quasi of node.quasis) inspectString(quasi, quasi.value.cooked);
|
|
118
|
+
for (const expr of node.expressions) walk(expr);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// cn()/clsx()/cva() arguments are reported by the CallExpression
|
|
122
|
+
// visitor — skip them here to avoid double-reporting.
|
|
123
|
+
if (
|
|
124
|
+
node.type === "CallExpression" &&
|
|
125
|
+
node.callee.type === "Identifier" &&
|
|
126
|
+
CLASS_HELPERS.has(node.callee.name)
|
|
127
|
+
) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const key of Object.keys(node)) {
|
|
131
|
+
if (key === "parent" || key === "loc" || key === "range") continue;
|
|
132
|
+
const child = node[key];
|
|
133
|
+
if (Array.isArray(child)) child.forEach(walk);
|
|
134
|
+
else if (child && typeof child.type === "string") walk(child);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
JSXAttribute(node) {
|
|
140
|
+
if (!isClassNameAttribute(node) || !node.value) return;
|
|
141
|
+
if (node.value.type === "Literal") {
|
|
142
|
+
inspectString(node.value, node.value.value);
|
|
143
|
+
} else if (node.value.type === "JSXExpressionContainer") {
|
|
144
|
+
walk(node.value.expression);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
CallExpression(node) {
|
|
148
|
+
const callee = node.callee;
|
|
149
|
+
if (callee.type !== "Identifier" || !CLASS_HELPERS.has(callee.name)) return;
|
|
150
|
+
for (const arg of node.arguments) walk(arg);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|