@seanyeh77/eslint-plugin-ui 0.0.0-stage → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hsiu-An Yeh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ import { ESLint, Linter, Rule } from "eslint";
2
+ declare const rules: {
3
+ 'no-raw-color': Rule.RuleModule;
4
+ 'no-tailwind-defaults': Rule.RuleModule;
5
+ 'no-native-controls': Rule.RuleModule;
6
+ 'no-nested-card': Rule.RuleModule;
7
+ 'one-primary-button': Rule.RuleModule;
8
+ 'verb-labels': Rule.RuleModule;
9
+ 'danger-confirm': Rule.RuleModule;
10
+ };
11
+ declare const plugin: ESLint.Plugin & {
12
+ configs: Record<string, Linter.Config>;
13
+ };
14
+ export { plugin as default, rules };
package/dist/index.js ADDED
@@ -0,0 +1,368 @@
1
+ import tsParser from "@typescript-eslint/parser";
2
+ // package.json
3
+ var name = "@seanyeh77/eslint-plugin-ui";
4
+ var version = "0.3.0";
5
+ // src/index.ts
6
+ const valueOf = (n) => n.value;
7
+ const COLOR = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/;
8
+ const URL_REF = /url\(\s*['"]?#[^)]*\)/g;
9
+ const NAMED = /^(black|white|red|green|blue|yellow|orange|purple|pink|gray|grey|brown|cyan|magenta|navy|teal|maroon|olive|lime|aqua|fuchsia|silver|gold|indigo|violet|crimson|tomato|coral|salmon|khaki|beige|ivory|lavender|tan|darkgray|darkgrey|lightgray|lightgrey|darkblue|lightblue|darkred|darkgreen|lightgreen)$/i;
10
+ const COLOR_KEY = /color|background|fill|stroke|border|outline|shadow/i;
11
+ const ARBITRARY = /-\[(#|rgba?\(|hsla?\(|oklch\()/;
12
+ function tagName(el) {
13
+ const n = el.name;
14
+ if (n.type === "JSXIdentifier") return n.name ?? "";
15
+ if (n.type === "JSXMemberExpression") return n.property?.name ?? "";
16
+ return "";
17
+ }
18
+ function attr(el, name) {
19
+ return el.attributes.find((a) => a.type === "JSXAttribute" && a.name?.name === name);
20
+ }
21
+ function literal(a) {
22
+ if (!a?.value) return a ? true : void 0;
23
+ if (a.value.type === "Literal") return a.value.value;
24
+ if (a.value.type === "JSXExpressionContainer" && a.value.expression?.type === "Literal") return a.value.expression.value;
25
+ }
26
+ function textOf(el) {
27
+ return el.children.map((c) => {
28
+ const n = c;
29
+ if (n.type === "JSXText") return n.value ?? "";
30
+ if (n.type === "JSXExpressionContainer" && n.expression?.type === "Literal") return String(n.expression.value);
31
+ return "\0";
32
+ }).join("").trim();
33
+ }
34
+ const TW_COLOR = new RegExp(`^(bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|inset-ring|outline|divide(?:-[xy])?|fill|stroke|from|via|to|placeholder|decoration|caret|accent|shadow|inset-shadow|drop-shadow|text-shadow)-(?:(?:slate|gray|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|[1-9]00|950)|white|black)(?:/(?:[\\d.]+|\\[[^\\]]+\\]|\\([^)]+\\)))?$`);
35
+ const TW_RADIUS = /^rounded(?:-[trblse]{1,2})?(?:-(?:xs|xl|2xl|3xl|4xl))?$/;
36
+ const TW_SHADOW = /^(?:shadow|drop-shadow|inset-shadow)(?:-(?:2xs|xs|md|lg|xl|2xl|inner))?$/;
37
+ const CLASS_FNS = "cn|clsx|cx|cva|twMerge|twJoin|tv|classnames";
38
+ const hasJsxClassAncestor = (n) => {
39
+ for (let p = n.parent; p; p = p.parent) {
40
+ const a = p;
41
+ if (a.type === "JSXAttribute") return a.name?.name === "className" || a.name?.name === "class";
42
+ }
43
+ return false;
44
+ };
45
+ const CLASS_CALL = new RegExp(`^(${CLASS_FNS})$`);
46
+ const isClassValue = (n) => {
47
+ let innerKey;
48
+ for (let c = n, p = n.parent; p; c = p, p = p.parent) {
49
+ const a = p;
50
+ if (a.type === "JSXAttribute") return a.name?.name === "className" || a.name?.name === "class";
51
+ if (a.type === "CallExpression") return CLASS_CALL.test(a.callee?.name ?? "");
52
+ if (a.type === "BinaryExpression" || a.type === "SwitchCase") return false;
53
+ if (a.type === "MemberExpression" && a.computed && a.property === c) return false;
54
+ if (a.type === "Property" && a.key === c) return hasClassCallParent(p);
55
+ if (a.type === "Property") {
56
+ const k = keyName(a.key);
57
+ innerKey ??= k;
58
+ if (k === "defaultVariants") return false;
59
+ if ((k === "compoundVariants" || k === "compoundSlots") && innerKey !== "class" && innerKey !== "className") return false;
60
+ }
61
+ }
62
+ return false;
63
+ };
64
+ const hasClassCallParent = (prop) => {
65
+ const obj = prop.parent;
66
+ if (obj?.type !== "ObjectExpression") return false;
67
+ let up = obj.parent;
68
+ while (up?.type === "ArrayExpression") up = up.parent;
69
+ return up?.type === "CallExpression" && CLASS_CALL.test(up.callee?.name ?? "");
70
+ };
71
+ const keyName = (k) => {
72
+ const key = k;
73
+ return key?.type === "Identifier" ? key.name : typeof key?.value === "string" ? key.value : void 0;
74
+ };
75
+ const TOKEN_HINT = {
76
+ text: "text-ink, text-ink-muted, text-accent-text, text-danger-text, text-on-accent",
77
+ bg: "bg-surface, bg-surface-sub, bg-accent, bg-accent-soft, bg-danger-soft",
78
+ border: "border-line, border-line-strong, border-accent"
79
+ };
80
+ const twClasses = (v) => v.split(/\s+/).filter(Boolean).map((c) => c.slice(c.lastIndexOf(":") + 1).replace(/^!|!$/g, ""));
81
+ const noTailwindDefaults = {
82
+ meta: {
83
+ type: "suggestion",
84
+ docs: { description: "Disallow Tailwind's default palette, radii and shadows: use the design system's token classes." },
85
+ schema: []
86
+ },
87
+ create(context) {
88
+ const check = (node, v) => {
89
+ for (const c of twClasses(v)) {
90
+ const color = TW_COLOR.exec(c);
91
+ if (color) {
92
+ const kind = color[1].startsWith("border") ? "border" : color[1];
93
+ context.report({
94
+ node,
95
+ message: "{{c}} is Tailwind's palette, not a design-system color. Use a token class: {{hint}}.",
96
+ data: {
97
+ c,
98
+ hint: TOKEN_HINT[kind] ?? `${color[1]}-ink, ${color[1]}-accent, ${color[1]}-line`
99
+ }
100
+ });
101
+ } else if (TW_RADIUS.test(c)) context.report({
102
+ node,
103
+ message: "{{c}} is not a design-system radius. Use rounded-sm, rounded-md or rounded-lg.",
104
+ data: { c }
105
+ });
106
+ else if (TW_SHADOW.test(c)) context.report({
107
+ node,
108
+ message: "{{c}} is not a design-system shadow. Use shadow-card, shadow-overlay or shadow-sm.",
109
+ data: { c }
110
+ });
111
+ }
112
+ };
113
+ return {
114
+ "JSXAttribute[name.name=/^(className|class)$/] Literal"(node) {
115
+ const v = valueOf(node);
116
+ if (typeof v === "string" && isClassValue(node)) check(node, v);
117
+ },
118
+ "JSXAttribute[name.name=/^(className|class)$/] TemplateElement"(node) {
119
+ if (isClassValue(node)) check(node, node.value.cooked ?? "");
120
+ },
121
+ [`CallExpression[callee.name=/^(${CLASS_FNS})$/] TemplateElement`](node) {
122
+ if (!hasJsxClassAncestor(node) && isClassValue(node)) check(node, node.value.cooked ?? "");
123
+ },
124
+ [`CallExpression[callee.name=/^(${CLASS_FNS})$/] Literal`](node) {
125
+ if (hasJsxClassAncestor(node)) return;
126
+ const v = valueOf(node);
127
+ if (typeof v === "string" && isClassValue(node)) check(node, v);
128
+ }
129
+ };
130
+ }
131
+ };
132
+ const noRawColor = {
133
+ meta: {
134
+ type: "problem",
135
+ docs: { description: "Colors come from tokens: var(--ink) or cssVar(\"ink\"), never a hex, rgb() or hsl() value." },
136
+ schema: []
137
+ },
138
+ create(context) {
139
+ const check = (node, value, key = "") => {
140
+ if (COLOR.test(value.replace(URL_REF, "")) || COLOR_KEY.test(key) && NAMED.test(value.trim())) context.report({
141
+ node,
142
+ message: "Hard-coded color \"{{v}}\". Use a token: var(--ink-muted) in CSS, cssVar(\"ink-muted\") from @seanyeh77/tokens in style props, or a component prop.",
143
+ data: { v: value.slice(0, 40) }
144
+ });
145
+ };
146
+ return {
147
+ "JSXAttribute[name.name=\"style\"] Literal"(node) {
148
+ const v = valueOf(node);
149
+ if (typeof v !== "string") return;
150
+ let p = node.parent;
151
+ let isValue = true;
152
+ for (let c = node; p && p.type !== "Property"; c = p, p = p.parent) {
153
+ const m = p;
154
+ if (m.type === "MemberExpression" && m.computed && m.property === c) return;
155
+ if (!(m.type === "ConditionalExpression" && m.test !== c || [
156
+ "LogicalExpression",
157
+ "TSAsExpression",
158
+ "TSSatisfiesExpression",
159
+ "TSNonNullExpression"
160
+ ].includes(m.type))) isValue = false;
161
+ }
162
+ if (!p) return;
163
+ const key = p.key;
164
+ if (key === node) return;
165
+ check(node, v, isValue ? String(key?.name ?? key?.value ?? "") : "");
166
+ },
167
+ "JSXAttribute[name.name=/^(className|class)$/] Literal"(node) {
168
+ const v = valueOf(node);
169
+ if (typeof v === "string" && ARBITRARY.test(v)) context.report({
170
+ node,
171
+ message: "Arbitrary color in \"{{v}}\". Use the token classes of @seanyeh77/tokens/tailwind.css: bg-surface, text-ink-muted.",
172
+ data: { v: v.slice(0, 40) }
173
+ });
174
+ },
175
+ "JSXAttribute[name.name=\"style\"] Property > TemplateLiteral > TemplateElement"(node) {
176
+ check(node, String(valueOf(node).raw));
177
+ },
178
+ "JSXAttribute[name.name=/^(fill|stroke|color|stopColor|floodColor)$/] Literal"(node) {
179
+ const v = valueOf(node);
180
+ if (typeof v === "string") check(node, v, "color");
181
+ }
182
+ };
183
+ }
184
+ };
185
+ const REPLACE = {
186
+ select: "Select (or MultiSelect)",
187
+ table: "DataTable",
188
+ dialog: "Dialog",
189
+ progress: "ProgressBar"
190
+ };
191
+ const INPUT_REPLACE = {
192
+ checkbox: "Checkbox",
193
+ radio: "RadioGroup",
194
+ date: "DatePicker",
195
+ search: "SearchField",
196
+ file: "FileUpload",
197
+ color: "AccentPicker"
198
+ };
199
+ const noNativeControls = {
200
+ meta: {
201
+ type: "problem",
202
+ docs: { description: "Use the design-system component instead of a native control it replaces." },
203
+ schema: []
204
+ },
205
+ create(context) {
206
+ return { JSXOpeningElement(node) {
207
+ const el = node;
208
+ const name = tagName(el);
209
+ if (REPLACE[name]) context.report({
210
+ node,
211
+ message: "Native <{{n}}>: use {{c}} from @seanyeh77/ui.",
212
+ data: {
213
+ n: name,
214
+ c: REPLACE[name]
215
+ }
216
+ });
217
+ if (name === "input") {
218
+ const t = literal(attr(el, "type"));
219
+ if (typeof t === "string" && INPUT_REPLACE[t]) context.report({
220
+ node,
221
+ message: "<input type=\"{{t}}\">: use {{c}} from @seanyeh77/ui.",
222
+ data: {
223
+ t,
224
+ c: INPUT_REPLACE[t]
225
+ }
226
+ });
227
+ }
228
+ } };
229
+ }
230
+ };
231
+ const noNestedCard = {
232
+ meta: {
233
+ type: "problem",
234
+ docs: { description: "No Card inside a Card: one shadow per block." },
235
+ schema: []
236
+ },
237
+ create(context) {
238
+ return { JSXElement(node) {
239
+ const el = node;
240
+ if (tagName(el.openingElement) !== "Card") return;
241
+ for (let p = el.parent; p; p = p.parent) if (p.type === "JSXElement" && tagName(p.openingElement) === "Card") {
242
+ context.report({
243
+ node,
244
+ message: "Card inside a Card gives two shadows. Use one Card and split it with headings, or place the Cards side by side."
245
+ });
246
+ return;
247
+ }
248
+ } };
249
+ }
250
+ };
251
+ const onePrimary = {
252
+ meta: {
253
+ type: "suggestion",
254
+ docs: { description: "At most one primary button per section." },
255
+ schema: []
256
+ },
257
+ create(context) {
258
+ const SECTIONS = /* @__PURE__ */ new Set([
259
+ "Card",
260
+ "Dialog",
261
+ "Drawer",
262
+ "section",
263
+ "form",
264
+ "SettingsSection"
265
+ ]);
266
+ return { JSXElement(node) {
267
+ const el = node;
268
+ if (tagName(el.openingElement) !== "Button" || literal(attr(el.openingElement, "variant")) !== "primary") return;
269
+ let section = el.parent;
270
+ while (section && !(section.type === "JSXElement" && SECTIONS.has(tagName(section.openingElement)))) section = section.parent;
271
+ if (!section) return;
272
+ const walk = (n) => {
273
+ if (!n || typeof n !== "object") return [];
274
+ if (Array.isArray(n)) return n.flatMap(walk);
275
+ const e = n;
276
+ if (typeof e.type !== "string") return [];
277
+ if (e.type === "ConditionalExpression") {
278
+ const a = walk(e.consequent);
279
+ const b = walk(e.alternate);
280
+ return [...walk(e.test), ...b.length > a.length ? b : a];
281
+ }
282
+ const own = [];
283
+ if (e.type === "JSXElement") {
284
+ if (e !== section && SECTIONS.has(tagName(e.openingElement))) return [];
285
+ if (tagName(e.openingElement) === "Button" && literal(attr(e.openingElement, "variant")) === "primary") own.push(e);
286
+ }
287
+ for (const k in e) if (k !== "parent" && k !== "loc" && k !== "range") own.push(...walk(e[k]));
288
+ return own;
289
+ };
290
+ const primaries = walk(section);
291
+ if (primaries.length > 1 && primaries.includes(el) && primaries[0] !== el) context.report({
292
+ node,
293
+ message: "A second primary Button in this section. Keep one primary (the main action) and make the others secondary."
294
+ });
295
+ } };
296
+ }
297
+ };
298
+ const VAGUE = /^(確定|確認|好|好的|OK|Ok|ok|Submit|submit|送出|是|否|Yes|No)$/;
299
+ const rules = {
300
+ "no-raw-color": noRawColor,
301
+ "no-tailwind-defaults": noTailwindDefaults,
302
+ "no-native-controls": noNativeControls,
303
+ "no-nested-card": noNestedCard,
304
+ "one-primary-button": onePrimary,
305
+ "verb-labels": {
306
+ meta: {
307
+ type: "suggestion",
308
+ docs: { description: "Button labels say what happens: 儲存, 刪除 api-gateway, 發布. Not 確定 or OK." },
309
+ schema: []
310
+ },
311
+ create(context) {
312
+ return { JSXElement(node) {
313
+ const el = node;
314
+ if (tagName(el.openingElement) !== "Button") return;
315
+ const t = textOf(el);
316
+ if (VAGUE.test(t)) context.report({
317
+ node,
318
+ message: "Button label \"{{t}}\" does not say what happens. Use the verb of the action: 儲存, 刪除, 發布.",
319
+ data: { t }
320
+ });
321
+ } };
322
+ }
323
+ },
324
+ "danger-confirm": {
325
+ meta: {
326
+ type: "problem",
327
+ docs: { description: "A danger Button is confirmed: pass confirm, or confirmed inside the confirm dialog." },
328
+ schema: []
329
+ },
330
+ create(context) {
331
+ return { JSXOpeningElement(node) {
332
+ const el = node;
333
+ if (tagName(el) !== "Button" || literal(attr(el, "variant")) !== "danger") return;
334
+ if (!attr(el, "confirm") && !attr(el, "confirmed")) context.report({
335
+ node,
336
+ message: "Danger Button without confirmation. Add confirm={{ title: \"刪除 …?\" }}, or confirmed when it is the button inside the confirm Dialog."
337
+ });
338
+ } };
339
+ }
340
+ }
341
+ };
342
+ const plugin = {
343
+ meta: {
344
+ name,
345
+ version
346
+ },
347
+ rules,
348
+ configs: {}
349
+ };
350
+ plugin.configs.recommended = {
351
+ name: "@seanyeh77/ui/recommended",
352
+ files: ["**/*.{jsx,tsx}"],
353
+ plugins: { ui: plugin },
354
+ languageOptions: {
355
+ parser: tsParser,
356
+ parserOptions: { ecmaFeatures: { jsx: true } }
357
+ },
358
+ rules: {
359
+ "ui/no-raw-color": "error",
360
+ "ui/no-tailwind-defaults": "error",
361
+ "ui/no-native-controls": "error",
362
+ "ui/no-nested-card": "error",
363
+ "ui/danger-confirm": "error",
364
+ "ui/one-primary-button": "warn",
365
+ "ui/verb-labels": "warn"
366
+ }
367
+ };
368
+ export { plugin as default, rules };
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@seanyeh77/eslint-plugin-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "ESLint rules for SeanYeh's design system: token colors only, design-system components instead of native controls, no card in a card, one primary button, verb labels.",
5
+ "license": "MIT",
6
+ "author": "Hsiu-An Yeh",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/seanyeh77/ui-design-system.git",
10
+ "directory": "packages/eslint-plugin-ui"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "peerDependencies": {
23
+ "eslint": ">=9",
24
+ "typescript": ">=4.8.4 <7"
25
+ },
26
+ "dependencies": {
27
+ "@typescript-eslint/parser": "^8.72.1"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "npm:@typescript/typescript6@^6.0.2"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "typescript": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "scripts": {
38
+ "build": "tsdown",
39
+ "typecheck": "tsc -p .",
40
+ "test": "vitest run --config vitest.config.ts"
41
+ }
6
42
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.