@adyen/eslint-plugin-bento 2.7.6 → 2.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.8.0 (2026-04-15)
4
+
5
+ ### Features
6
+
7
+ - added `tailwind-v3-to-bento` ESlint rule to help with the migration to Bento's Tailwind plugin ([d941b691a](https://github.com/Adyen/bento/commit/d941b691a))
8
+
9
+ ### ❤️ Thank You
10
+
11
+ - daver
12
+
13
+
14
+ ## 2.7.7 (2026-04-08)
15
+
16
+ This was a version bump only for eslint to align it with other projects, there were no code changes.
17
+
18
+
3
19
  ## 2.7.6 (2026-04-01)
4
20
 
5
21
  This was a version bump only for eslint to align it with other projects, there were no code changes.
package/README.md CHANGED
@@ -51,6 +51,22 @@ module.exports = defineConfig([
51
51
  ]);
52
52
  ```
53
53
 
54
+ ### Flat config tailwind
55
+
56
+ For projects migrating from Tailwind CSS v3 to Bento design tokens:
57
+
58
+ ```javascript
59
+ const { defineConfig } = require('eslint/config');
60
+ const bento = require('@adyen/eslint-plugin-bento');
61
+
62
+ module.exports = defineConfig([
63
+ {
64
+ // Other configs
65
+ },
66
+ ...bento.configs['tailwind'],
67
+ ]);
68
+ ```
69
+
54
70
  ### Custom
55
71
 
56
72
  Without the recommended configs:
@@ -70,8 +86,9 @@ module.exports = defineConfig([
70
86
  },
71
87
  rules: {
72
88
  '@adyen/bento/vue-old-spacer-tokens': 'warn',
73
- '@adyen/bento/deprecation-currency-default-slot': 'warn'
74
- '@adyen/bento/deprecation-tag-default-slot': 'warn'
89
+ '@adyen/bento/deprecation-currency-default-slot': 'warn',
90
+ '@adyen/bento/deprecation-tag-default-slot': 'warn',
91
+ '@adyen/bento/tailwind-v3-to-bento': 'warn',
75
92
  },
76
93
  files: ['**/*.vue'],
77
94
  },
@@ -84,28 +101,44 @@ module.exports = defineConfig([
84
101
 
85
102
  Disallow the use of ADL space utility classes in favor of Bento _spacer_ utility classes.
86
103
 
87
- ### Recommended
104
+ ### `@adyen/bento/deprecation-currency-default-slot`
88
105
 
89
- The recommended configuration contains pre-configured rules and their severity
106
+ Disallow the use of the `default` slot in `bento-currency` instances.
90
107
 
91
- - `@adyen/bento/vue-old-spacer-tokens`: `warn`
108
+ ### `@adyen/bento/deprecation-tag-default-slot`
92
109
 
93
- ### `@adyen/bento/deprecation-currency-default-slot`
110
+ Disallow the use of the `default` slot in `bento-tag` instances.
94
111
 
95
- Disallow the use of the `default` slot in `bento-currency` instances
112
+ ### `@adyen/bento/tailwind-v3-to-bento`
96
113
 
97
- ### Recommended
114
+ Replace Tailwind CSS v3 utility classes with Bento design token equivalents. Covers spacing, border radius, border
115
+ width, box shadows, breakpoints, and colors (auto-fixable via `eslint --fix`). Typography classes are flagged with a
116
+ warning — use the `bento-typography` component instead.
98
117
 
99
- The recommended configuration contains pre-configured rules and their severity
118
+ ## Configurations
100
119
 
101
- - `@adyen/bento/deprecation-currency-default-slot`: `warn`
120
+ ### Recommended (`vue-recommended`)
102
121
 
103
- ### `@adyen/bento/deprecation-tag-default-slot`
122
+ | Rule | Severity |
123
+ | ------------------------------------------------ | -------- |
124
+ | `@adyen/bento/deprecation-components-input-emit` | `warn` |
125
+ | `@adyen/bento/deprecation-components-value-prop` | `warn` |
126
+ | `@adyen/bento/deprecation-currency-default-slot` | `warn` |
127
+ | `@adyen/bento/deprecation-tag-default-slot` | `warn` |
128
+ | `@adyen/bento/vue-old-spacer-tokens` | `warn` |
104
129
 
105
- Disallow the use of the `default` slot in `bento-tag` instances
130
+ ### Strict (`vue-strict`)
106
131
 
107
- ### Recommended
132
+ | Rule | Severity |
133
+ | ------------------------------------------------ | -------- |
134
+ | `@adyen/bento/deprecation-components-input-emit` | `error` |
135
+ | `@adyen/bento/deprecation-components-value-prop` | `error` |
136
+ | `@adyen/bento/deprecation-currency-default-slot` | `error` |
137
+ | `@adyen/bento/deprecation-tag-default-slot` | `error` |
138
+ | `@adyen/bento/vue-old-spacer-tokens` | `error` |
108
139
 
109
- The recommended configuration contains pre-configured rules and their severity
140
+ ### Tailwind (`tailwind`)
110
141
 
111
- - `@adyen/bento/deprecation-tag-default-slot`: `warn`
142
+ | Rule | Severity |
143
+ | ----------------------------------- | -------- |
144
+ | `@adyen/bento/tailwind-v3-to-bento` | `warn` |
@@ -0,0 +1,17 @@
1
+ module.exports = [
2
+ {
3
+ name: '@bento/adyen/tailwind',
4
+ files: ['*.vue', '**/*.vue'],
5
+ languageOptions: {
6
+ parser: require('vue-eslint-parser'),
7
+ },
8
+ plugins: {
9
+ get '@adyen/bento'() {
10
+ return require('../index');
11
+ },
12
+ },
13
+ rules: {
14
+ '@adyen/bento/tailwind-v3-to-bento': 'warn',
15
+ },
16
+ },
17
+ ];
package/index.js CHANGED
@@ -9,6 +9,7 @@ const plugin = {
9
9
  configs: {
10
10
  'vue-recommended': require('./configs/vue-recommended'),
11
11
  'vue-strict': require('./configs/vue-strict'),
12
+ tailwind: require('./configs/tailwind'),
12
13
  },
13
14
  rules: {
14
15
  'deprecation-components-input-emit': require('./rules/deprecation-components-input-emit/deprecation-components-input-emit'),
@@ -16,6 +17,7 @@ const plugin = {
16
17
  'deprecation-currency-default-slot': require('./rules/deprecation-currency-default-slot/deprecation-currency-default-slot'),
17
18
  'deprecation-tag-default-slot': require('./rules/deprecation-tag-default-slot/deprecation-tag-default-slot'),
18
19
  'vue-old-spacer-tokens': require('./rules/vue-old-spacer-tokens/vue-old-spacer-tokens'),
20
+ 'tailwind-v3-to-bento': require('./rules/tailwind-v3-to-bento/tailwind-v3-to-bento'),
19
21
  },
20
22
  };
21
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adyen/eslint-plugin-bento",
3
- "version": "2.7.6",
3
+ "version": "2.8.0",
4
4
  "description": "Adyen Bento ESLint rules",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -0,0 +1,114 @@
1
+ # @adyen/bento/tailwind-v3-to-bento
2
+
3
+ > Replace Tailwind CSS v3 utility classes with Bento design token equivalents.
4
+
5
+ - This rule is included in the `plugin:@adyen/bento/recommended` configuration
6
+ - The `--fix` option can automatically fix most problems reported by this rule
7
+
8
+ This rule helps developers migrate from Tailwind CSS v3 default utility classes to Bento design token equivalents. It
9
+ covers spacing, border radius, border width, box shadows, breakpoints, and colors. Typography classes are flagged but
10
+ not auto-fixed — use the `bento-typography` component instead.
11
+
12
+ ## Auto-fixable mappings
13
+
14
+ ### Spacing
15
+
16
+ | Before (Tailwind 3) | After (Bento) | Value |
17
+ |---|---|---|
18
+ | `p-0` | `p-000` | 0px |
19
+ | `p-0.5` | `p-010` | 2px |
20
+ | `p-1` | `p-020` | 4px |
21
+ | `p-1.5` | `p-030` | 6px |
22
+ | `p-2` | `p-040` | 8px |
23
+ | `p-2.5` | `p-050` | 10px |
24
+ | `p-3` | `p-060` | 12px |
25
+ | `p-4` | `p-070` | 16px |
26
+ | `p-5` | `p-080` | 20px |
27
+ | `p-6` | `p-090` | 24px |
28
+ | `p-8` | `p-100` | 32px |
29
+ | `p-10` | `p-110` | 40px |
30
+ | `p-12` | `p-120` | 48px |
31
+ | `p-14` | `p-130` | 56px |
32
+ | `p-16` | `p-140` | 64px |
33
+
34
+ The same mapping applies to all spacing utilities (`m-*`, `mx-*`, `my-*`, `gap-*`, `space-x-*`, `space-y-*`, `w-*`,
35
+ `h-*`, `inset-*`, `top-*`, `right-*`, `bottom-*`, `left-*`, etc.).
36
+
37
+ ### Border radius
38
+
39
+ | Before | After | Value |
40
+ |---|---|---|
41
+ | `rounded` | `rounded-s` | 4px |
42
+ | `rounded-sm` | `rounded-xs` | 2px |
43
+ | `rounded-md` | `rounded-m` | 8px |
44
+ | `rounded-lg` | `rounded-l` | 12px |
45
+ | `rounded-xl` | `rounded-xl` | 24px |
46
+
47
+ ### Border width
48
+
49
+ | Before | After | Value |
50
+ |---|---|---|
51
+ | `border` | `border-s` | 1px |
52
+ | `border-2` | `border-m` | 2px |
53
+ | `border-4` | `border-l` | 3px |
54
+
55
+ ### Box shadows
56
+
57
+ | Before | After |
58
+ |---|---|
59
+ | `shadow` | `shadow-medium` |
60
+ | `shadow-sm` | `shadow-low` |
61
+ | `shadow-md` | `shadow-medium` |
62
+ | `shadow-lg` | `shadow-high` |
63
+ | `shadow-xl` | `shadow-high` |
64
+ | `shadow-2xl` | `shadow-high` |
65
+
66
+ ### Breakpoints
67
+
68
+ | Before | After | Value |
69
+ |---|---|---|
70
+ | `sm:` | `s-min:` | min-width: 376px |
71
+ | `md:` | `m-min:` | min-width: 500px |
72
+ | `lg:` | `l-min:` | min-width: 768px |
73
+ | `xl:` | `xl-min:` | min-width: 1280px |
74
+ | `2xl:` | `xxl:` | min-width: 1536px |
75
+
76
+ ### Colors (best-effort)
77
+
78
+ Color mappings are best-effort since Tailwind 3 uses raw color values while Bento uses semantic tokens. Examples:
79
+
80
+ | Before | After |
81
+ |---|---|
82
+ | `text-red-500` | `text-label-critical` |
83
+ | `text-green-500` | `text-label-success` |
84
+ | `text-blue-500` | `text-label-highlight` |
85
+ | `text-gray-500` | `text-label-secondary` |
86
+ | `bg-gray-100` | `bg-secondary` |
87
+ | `bg-red-100` | `bg-critical-weak` |
88
+ | `border-gray-300` | `border-outline-primary` |
89
+
90
+ ## Warn only (no auto-fix)
91
+
92
+ ### Typography
93
+
94
+ Typography classes are flagged with a warning but not auto-fixed. Use the `bento-typography` component instead.
95
+
96
+ Detected patterns: `text-sm`, `text-lg`, `font-bold`, `font-sans`, `leading-tight`, `tracking-wide`, etc.
97
+
98
+ ## ✗ BAD
99
+
100
+ ```html
101
+ <template>
102
+ <div class="p-4 m-2 rounded-md shadow-lg sm:p-8 text-red-500">Content</div>
103
+ <p class="text-sm font-bold leading-tight">Hello</p>
104
+ </template>
105
+ ```
106
+
107
+ ## ✓ GOOD
108
+
109
+ ```html
110
+ <template>
111
+ <div class="p-070 m-040 rounded-m shadow-high s-min:p-100 text-label-critical">Content</div>
112
+ <bento-typography variant="body" stronger>Hello</bento-typography>
113
+ </template>
114
+ ```
@@ -0,0 +1,24 @@
1
+ const { defineConfig } = require('eslint/config');
2
+ const vueParser = require('vue-eslint-parser');
3
+ const tsParser = require('@typescript-eslint/parser');
4
+ const adyenBento = require('@adyen/eslint-plugin-bento');
5
+
6
+ module.exports = defineConfig([
7
+ {
8
+ languageOptions: {
9
+ ecmaVersion: 'latest',
10
+ sourceType: 'module',
11
+ parser: vueParser,
12
+ parserOptions: {
13
+ parser: tsParser,
14
+ },
15
+ },
16
+ plugins: {
17
+ '@adyen/bento': adyenBento,
18
+ },
19
+ rules: {
20
+ '@adyen/bento/tailwind-v3-to-bento': 'warn',
21
+ },
22
+ files: ['**/*.vue'],
23
+ },
24
+ ]);
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <div>
3
+ <div class="p-4 m-2 gap-8"></div>
4
+ <div class="rounded rounded-md rounded-lg"></div>
5
+ <div class="border border-2 border-4"></div>
6
+ <div class="shadow shadow-sm shadow-lg"></div>
7
+ <div class="sm:p-4 lg:p-8 2xl:m-2"></div>
8
+ <div class="text-red-500 bg-gray-100 border-gray-300"></div>
9
+ <div class="text-sm font-bold leading-tight tracking-wide"></div>
10
+ <div class="p-4 rounded-md shadow-lg sm:p-8 text-red-500"></div>
11
+ <div class="flex items-center justify-between"></div>
12
+ </div>
13
+ </template>
14
+
15
+ <script setup></script>
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import { execFileSyncEslintVueFiles } from '../../utils/exec-file-sync-eslint-vue-files';
3
+
4
+ describe('@adyen/bento/tailwind-v3-to-bento', () => {
5
+ it('should lint with warning', () => {
6
+ const eslintrc = path.resolve(__dirname, '__tests__', 'eslint.config.js');
7
+ const vueFile = path.resolve(__dirname, '__tests__', 'tailwind-v3-to-bento.vue');
8
+
9
+ const result = JSON.parse(execFileSyncEslintVueFiles(eslintrc, vueFile));
10
+
11
+ // 7 fixable warnings + 4 typography warnings = 8 total messages (typography classes are per-class)
12
+ expect(result[0].warningCount).toBeGreaterThan(0);
13
+
14
+ // Verify spacing replacement
15
+ const spacingMessage = result[0].messages.find(
16
+ (m: { ruleId: string; line: number }) => m.ruleId === '@adyen/bento/tailwind-v3-to-bento' && m.line === 3
17
+ );
18
+ expect(spacingMessage).toBeDefined();
19
+ expect(spacingMessage.messageId).toBe('updateTailwindClass');
20
+
21
+ // Verify typography warning includes suggested bento-typography usage
22
+ const typographyMessage = result[0].messages.find(
23
+ (m: { messageId: string }) => m.messageId === 'removeTailwindTypography'
24
+ );
25
+ expect(typographyMessage).toBeDefined();
26
+ expect(typographyMessage.message).toContain('<bento-typography');
27
+ expect(typographyMessage.message).toContain('variant=');
28
+ });
29
+ });
@@ -0,0 +1,377 @@
1
+ /**
2
+ * @typedef {import('eslint').Rule.RuleContext} RuleContext
3
+ */
4
+ /**
5
+ * @typedef {import('estree').Node} Node
6
+ */
7
+ /**
8
+ * @typedef {import('vue-eslint-parser').AST.VLiteral} VLiteral
9
+ */
10
+ /**
11
+ * @typedef {import('eslint-plugin-vue/lib/utils').RuleModule} RuleModule
12
+ */
13
+
14
+ const VueUtils = require('eslint-plugin-vue/lib/utils');
15
+
16
+ // Spacing: Tailwind 3 -> Bento spacer tokens
17
+ const SPACING_MAP = {
18
+ '0': '000',
19
+ '0.5': '010',
20
+ '1': '020',
21
+ '1.5': '030',
22
+ '2': '040',
23
+ '2.5': '050',
24
+ '3': '060',
25
+ '4': '070',
26
+ '5': '080',
27
+ '6': '090',
28
+ '8': '100',
29
+ '10': '110',
30
+ '12': '120',
31
+ '14': '130',
32
+ '16': '140',
33
+ };
34
+
35
+ // Border radius: Tailwind 3 -> Bento border radius tokens
36
+ const BORDER_RADIUS_MAP = {
37
+ 'rounded-sm': 'rounded-xs',
38
+ 'rounded-md': 'rounded-m',
39
+ 'rounded-lg': 'rounded-l',
40
+ 'rounded-xl': 'rounded-xl',
41
+ };
42
+
43
+ // Border width: Tailwind 3 -> Bento border width tokens
44
+ const BORDER_WIDTH_MAP = {
45
+ 'border-0': 'border-0',
46
+ 'border-2': 'border-m',
47
+ 'border-4': 'border-l',
48
+ };
49
+
50
+ // Box shadow: Tailwind 3 -> Bento shadow tokens
51
+ const SHADOW_MAP = {
52
+ 'shadow-sm': 'shadow-low',
53
+ 'shadow-md': 'shadow-medium',
54
+ 'shadow-lg': 'shadow-high',
55
+ 'shadow-xl': 'shadow-high',
56
+ 'shadow-2xl': 'shadow-high',
57
+ };
58
+
59
+ // Breakpoint prefixes: Tailwind 3 -> Bento breakpoint prefixes
60
+ const BREAKPOINT_MAP = {
61
+ 'sm:': 's-min:',
62
+ 'md:': 'm-min:',
63
+ 'lg:': 'l-min:',
64
+ 'xl:': 'xl-min:',
65
+ '2xl:': 'xxl:',
66
+ };
67
+
68
+ // Color utilities: Tailwind 3 -> Bento color tokens (best-effort mapping)
69
+ const COLOR_MAP = {
70
+ 'red-500': 'label-critical',
71
+ 'red-600': 'label-critical',
72
+ 'red-700': 'label-critical-hover',
73
+ 'red-100': 'critical-weak',
74
+ 'green-500': 'label-success',
75
+ 'green-600': 'label-success',
76
+ 'green-100': 'success-weak',
77
+ 'blue-500': 'label-highlight',
78
+ 'blue-600': 'label-highlight',
79
+ 'blue-100': 'highlight-weak',
80
+ 'yellow-500': 'label-warning',
81
+ 'yellow-600': 'label-warning',
82
+ 'yellow-100': 'warning-weak',
83
+ 'gray-50': 'secondary',
84
+ 'gray-100': 'secondary',
85
+ 'gray-200': 'tertiary',
86
+ 'gray-300': 'outline-primary',
87
+ 'gray-400': 'outline-secondary',
88
+ 'gray-500': 'label-secondary',
89
+ 'gray-600': 'label-secondary',
90
+ 'gray-700': 'label-primary',
91
+ 'gray-800': 'label-primary',
92
+ 'gray-900': 'label-primary',
93
+ white: 'primary',
94
+ black: 'label-primary',
95
+ };
96
+
97
+ // Color prefix utilities that use the COLOR_MAP
98
+ const COLOR_PREFIXES = ['text-', 'bg-', 'border-'];
99
+
100
+ // Spacing prefix patterns (padding, margin, gap, space, inset, etc.)
101
+ const SPACING_PREFIXES = [
102
+ 'p',
103
+ 'px',
104
+ 'py',
105
+ 'pt',
106
+ 'pr',
107
+ 'pb',
108
+ 'pl',
109
+ 'ps',
110
+ 'pe',
111
+ 'm',
112
+ 'mx',
113
+ 'my',
114
+ 'mt',
115
+ 'mr',
116
+ 'mb',
117
+ 'ml',
118
+ 'ms',
119
+ 'me',
120
+ 'gap',
121
+ 'gap-x',
122
+ 'gap-y',
123
+ 'space-x',
124
+ 'space-y',
125
+ 'inset',
126
+ 'inset-x',
127
+ 'inset-y',
128
+ 'top',
129
+ 'right',
130
+ 'bottom',
131
+ 'left',
132
+ 'start',
133
+ 'end',
134
+ 'w',
135
+ 'min-w',
136
+ 'max-w',
137
+ 'h',
138
+ 'min-h',
139
+ 'max-h',
140
+ 'size',
141
+ 'basis',
142
+ ];
143
+
144
+ // Typography utilities that should be flagged (no auto-fix, use bento-typography)
145
+ const TYPOGRAPHY_PATTERNS = [
146
+ /^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,
147
+ /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,
148
+ /^font-(sans|serif|mono)$/,
149
+ /^leading-(none|tight|snug|normal|relaxed|loose|\d+)$/,
150
+ /^tracking-(tighter|tight|normal|wide|wider|widest)$/,
151
+ ];
152
+
153
+ // Font size -> suggested bento-typography variant
154
+ const FONT_SIZE_SUGGESTION = {
155
+ 'text-xs': 'caption',
156
+ 'text-sm': 'caption',
157
+ 'text-base': 'body',
158
+ 'text-lg': 'title',
159
+ 'text-xl': 'title" medium',
160
+ 'text-2xl': 'title" large',
161
+ 'text-3xl': 'title" large',
162
+ 'text-4xl': 'title" large',
163
+ 'text-5xl': 'title" large',
164
+ 'text-6xl': 'title" large',
165
+ 'text-7xl': 'title" large',
166
+ 'text-8xl': 'title" large',
167
+ 'text-9xl': 'title" large',
168
+ };
169
+
170
+ // Font weight -> suggested bento-typography modifier prop
171
+ const FONT_WEIGHT_SUGGESTION = {
172
+ 'font-medium': 'stronger',
173
+ 'font-semibold': 'stronger',
174
+ 'font-bold': 'strongest',
175
+ 'font-extrabold': 'strongest',
176
+ 'font-black': 'strongest',
177
+ };
178
+
179
+ /**
180
+ * Build a suggested bento-typography usage from detected typography classes.
181
+ * @param {string[]} typographyClasses
182
+ * @returns {string}
183
+ */
184
+ function buildTypographySuggestion(typographyClasses) {
185
+ let variant = 'body';
186
+ const props = [];
187
+
188
+ for (const cls of typographyClasses) {
189
+ if (FONT_SIZE_SUGGESTION[cls]) {
190
+ const suggestion = FONT_SIZE_SUGGESTION[cls];
191
+ if (suggestion.includes('" ')) {
192
+ const [v, prop] = suggestion.split('" ');
193
+ variant = v;
194
+ props.push(prop);
195
+ } else {
196
+ variant = suggestion;
197
+ }
198
+ }
199
+ if (FONT_WEIGHT_SUGGESTION[cls]) {
200
+ props.push(FONT_WEIGHT_SUGGESTION[cls]);
201
+ }
202
+ }
203
+
204
+ const propsStr = props.length > 0 ? ' ' + props.join(' ') : '';
205
+ return `<bento-typography variant="${variant}"${propsStr}>`;
206
+ }
207
+
208
+ /**
209
+ * Try to replace a single CSS class with its Bento equivalent.
210
+ * @param {string} cssClass
211
+ * @returns {{ replaced: string, wasReplaced: boolean, isTypography: boolean }}
212
+ */
213
+ function replaceClass(cssClass) {
214
+ let workingClass = cssClass;
215
+ let breakpointPrefix = '';
216
+ let hasVariantReplacement = false;
217
+
218
+ // Extract all variants (e.g., md:hover:p-4 -> variants=['m-min:', 'hover:'], workingClass='p-4')
219
+ while (workingClass.includes(':')) {
220
+ const colonIndex = workingClass.indexOf(':') + 1;
221
+ const variant = workingClass.slice(0, colonIndex);
222
+ const mapped = BREAKPOINT_MAP[variant];
223
+ if (mapped) {
224
+ hasVariantReplacement = true;
225
+ breakpointPrefix += mapped;
226
+ } else {
227
+ breakpointPrefix += variant;
228
+ }
229
+ workingClass = workingClass.slice(colonIndex);
230
+ }
231
+
232
+ // Check typography (warn only)
233
+ for (const pattern of TYPOGRAPHY_PATTERNS) {
234
+ if (pattern.test(workingClass)) {
235
+ return { replaced: cssClass, wasReplaced: false, isTypography: true };
236
+ }
237
+ }
238
+
239
+ // Check border radius: bare "rounded" (without further suffix) maps to "rounded-s"
240
+ if (workingClass === 'rounded') {
241
+ return { replaced: breakpointPrefix + 'rounded-s', wasReplaced: true, isTypography: false };
242
+ }
243
+ if (BORDER_RADIUS_MAP[workingClass]) {
244
+ return { replaced: breakpointPrefix + BORDER_RADIUS_MAP[workingClass], wasReplaced: true, isTypography: false };
245
+ }
246
+
247
+ // Check border width: bare "border" (without color suffix) maps to "border-s"
248
+ if (workingClass === 'border') {
249
+ return { replaced: breakpointPrefix + 'border-s', wasReplaced: true, isTypography: false };
250
+ }
251
+ if (BORDER_WIDTH_MAP[workingClass]) {
252
+ return { replaced: breakpointPrefix + BORDER_WIDTH_MAP[workingClass], wasReplaced: true, isTypography: false };
253
+ }
254
+
255
+ // Check box shadow: bare "shadow" maps to "shadow-medium"
256
+ if (workingClass === 'shadow') {
257
+ return { replaced: breakpointPrefix + 'shadow-medium', wasReplaced: true, isTypography: false };
258
+ }
259
+ if (SHADOW_MAP[workingClass]) {
260
+ return { replaced: breakpointPrefix + SHADOW_MAP[workingClass], wasReplaced: true, isTypography: false };
261
+ }
262
+
263
+ // Check color utilities
264
+ for (const prefix of COLOR_PREFIXES) {
265
+ if (workingClass.startsWith(prefix)) {
266
+ const colorPart = workingClass.slice(prefix.length);
267
+ if (COLOR_MAP[colorPart]) {
268
+ const bentoColor = COLOR_MAP[colorPart];
269
+ return { replaced: breakpointPrefix + prefix + bentoColor, wasReplaced: true, isTypography: false };
270
+ }
271
+ }
272
+ }
273
+
274
+ // Check spacing utilities (including negative values like -m-4)
275
+ for (const spacingPrefix of SPACING_PREFIXES) {
276
+ const isNegative = workingClass.startsWith('-');
277
+ const baseClass = isNegative ? workingClass.slice(1) : workingClass;
278
+ const prefixWithDash = spacingPrefix + '-';
279
+ if (baseClass.startsWith(prefixWithDash)) {
280
+ const value = baseClass.slice(prefixWithDash.length);
281
+ if (SPACING_MAP[value]) {
282
+ return {
283
+ replaced: breakpointPrefix + (isNegative ? '-' : '') + prefixWithDash + SPACING_MAP[value],
284
+ wasReplaced: true,
285
+ isTypography: false,
286
+ };
287
+ }
288
+ }
289
+ }
290
+
291
+ // If a breakpoint variant was mapped but the base class wasn't, still report the replacement
292
+ if (hasVariantReplacement && workingClass) {
293
+ return { replaced: breakpointPrefix + workingClass, wasReplaced: true, isTypography: false };
294
+ }
295
+
296
+ return { replaced: cssClass, wasReplaced: false, isTypography: false };
297
+ }
298
+
299
+ /** @type {RuleModule} */
300
+ module.exports = {
301
+ meta: {
302
+ type: 'suggestion',
303
+ docs: {
304
+ description: 'Replace Tailwind CSS v3 utility classes with Bento design token equivalents',
305
+ category: 'Best Practices',
306
+ url: 'https://github.com/Adyen/bento/blob/main/packages/eslint/rules/tailwind-v3-to-bento/README.md',
307
+ recommended: true,
308
+ },
309
+ messages: {
310
+ updateTailwindClass:
311
+ 'Found Tailwind v3 class "{{ oldClass }}" — replace with Bento equivalent "{{ newClass }}".',
312
+ removeTailwindTypography:
313
+ 'Found Tailwind v3 typography class "{{ oldClass }}" — use {{ suggestion }} instead.',
314
+ },
315
+ fixable: 'code',
316
+ schema: [],
317
+ },
318
+ /** @param {RuleContext} context */
319
+ create(context) {
320
+ return VueUtils.defineTemplateBodyVisitor(context, {
321
+ VLiteral: (/** @type {any} */ node) => {
322
+ if (node.parent && node.parent.key && node.parent.key.name !== 'class') {
323
+ return;
324
+ }
325
+
326
+ if (typeof node.value !== 'string') {
327
+ return;
328
+ }
329
+
330
+ const classList = /** @type {VLiteral['value']} */ node.value;
331
+ let hasReplacements = false;
332
+ let hasTypography = false;
333
+
334
+ const classes = classList.split(/\s+/).filter(Boolean);
335
+ const results = classes.map(cssClass => {
336
+ const result = replaceClass(cssClass);
337
+ if (result.wasReplaced) {
338
+ hasReplacements = true;
339
+ }
340
+ if (result.isTypography) {
341
+ hasTypography = true;
342
+ }
343
+ return result;
344
+ });
345
+
346
+ if (hasReplacements) {
347
+ const fixed = results.map(r => r.replaced).join(' ');
348
+ context.report({
349
+ node,
350
+ messageId: 'updateTailwindClass',
351
+ data: {
352
+ oldClass: classList,
353
+ newClass: fixed,
354
+ },
355
+ fix(fixer) {
356
+ const quote = context.sourceCode.getText(node)[0];
357
+ return fixer.replaceText(node, `${quote}${fixed}${quote}`);
358
+ },
359
+ });
360
+ }
361
+
362
+ if (hasTypography) {
363
+ const typographyClasses = results.filter(r => r.isTypography).map(r => r.replaced);
364
+ const suggestion = buildTypographySuggestion(typographyClasses);
365
+ context.report({
366
+ node,
367
+ messageId: 'removeTailwindTypography',
368
+ data: {
369
+ oldClass: typographyClasses.join(', '),
370
+ suggestion,
371
+ },
372
+ });
373
+ }
374
+ },
375
+ });
376
+ },
377
+ };
@@ -0,0 +1,403 @@
1
+ import { RuleTester } from 'eslint';
2
+ import vueParser from 'vue-eslint-parser';
3
+ import tsParser from '@typescript-eslint/parser';
4
+ import tailwindV3ToBento from './tailwind-v3-to-bento';
5
+
6
+ const ruleTester = new RuleTester({
7
+ languageOptions: {
8
+ ecmaVersion: 'latest',
9
+ sourceType: 'module',
10
+ parser: vueParser,
11
+ parserOptions: {
12
+ parser: tsParser,
13
+ },
14
+ },
15
+ });
16
+
17
+ describe('@adyen/bento/tailwind-v3-to-bento', () => {
18
+ ruleTester.run('tailwind-v3-to-bento', tailwindV3ToBento, {
19
+ valid: [
20
+ {
21
+ name: 'Bento spacing classes are valid',
22
+ filename: 'test.vue',
23
+ code: `
24
+ <template>
25
+ <div class="p-070 m-040 gap-100"></div>
26
+ </template>
27
+ `,
28
+ },
29
+ {
30
+ name: 'Bento border radius classes are valid',
31
+ filename: 'test.vue',
32
+ code: `
33
+ <template>
34
+ <div class="rounded-s rounded-m rounded-l"></div>
35
+ </template>
36
+ `,
37
+ },
38
+ {
39
+ name: 'Bento shadow classes are valid',
40
+ filename: 'test.vue',
41
+ code: `
42
+ <template>
43
+ <div class="shadow-low shadow-medium shadow-high"></div>
44
+ </template>
45
+ `,
46
+ },
47
+ {
48
+ name: 'Bento breakpoint classes are valid',
49
+ filename: 'test.vue',
50
+ code: `
51
+ <template>
52
+ <div class="s-min:p-070 l-min:p-100 xxl:m-040"></div>
53
+ </template>
54
+ `,
55
+ },
56
+ {
57
+ name: 'Bento color classes are valid',
58
+ filename: 'test.vue',
59
+ code: `
60
+ <template>
61
+ <div class="text-label-critical bg-secondary border-outline-primary"></div>
62
+ </template>
63
+ `,
64
+ },
65
+ {
66
+ name: 'Non-themed Tailwind utilities are valid',
67
+ filename: 'test.vue',
68
+ code: `
69
+ <template>
70
+ <div class="flex items-center justify-between"></div>
71
+ </template>
72
+ `,
73
+ },
74
+ {
75
+ name: 'Non-class attributes are ignored',
76
+ filename: 'test.vue',
77
+ code: `
78
+ <template>
79
+ <div data-test="p-4 shadow-lg rounded-md"></div>
80
+ </template>
81
+ `,
82
+ },
83
+ ],
84
+ invalid: [
85
+ // Spacing
86
+ {
87
+ name: 'Replaces spacing utilities',
88
+ code: `
89
+ <template>
90
+ <div class="p-4 m-2 gap-8"></div>
91
+ </template>
92
+ `,
93
+ output: `
94
+ <template>
95
+ <div class="p-070 m-040 gap-100"></div>
96
+ </template>
97
+ `,
98
+ errors: [{ messageId: 'updateTailwindClass' }],
99
+ },
100
+ {
101
+ name: 'Replaces spacing variants (px, py, mt, etc.)',
102
+ code: `
103
+ <template>
104
+ <div class="px-4 py-2 mt-8 mb-3"></div>
105
+ </template>
106
+ `,
107
+ output: `
108
+ <template>
109
+ <div class="px-070 py-040 mt-100 mb-060"></div>
110
+ </template>
111
+ `,
112
+ errors: [{ messageId: 'updateTailwindClass' }],
113
+ },
114
+ {
115
+ name: 'Replaces fractional spacing',
116
+ code: `
117
+ <template>
118
+ <div class="p-0.5 m-1.5 gap-2.5"></div>
119
+ </template>
120
+ `,
121
+ output: `
122
+ <template>
123
+ <div class="p-010 m-030 gap-050"></div>
124
+ </template>
125
+ `,
126
+ errors: [{ messageId: 'updateTailwindClass' }],
127
+ },
128
+ {
129
+ name: 'Replaces negative spacing utilities',
130
+ code: `
131
+ <template>
132
+ <div class="-m-4 -mt-2 -inset-x-8"></div>
133
+ </template>
134
+ `,
135
+ output: `
136
+ <template>
137
+ <div class="-m-070 -mt-040 -inset-x-100"></div>
138
+ </template>
139
+ `,
140
+ errors: [{ messageId: 'updateTailwindClass' }],
141
+ },
142
+ // Border radius
143
+ {
144
+ name: 'Replaces bare rounded',
145
+ code: `
146
+ <template>
147
+ <div class="rounded"></div>
148
+ </template>
149
+ `,
150
+ output: `
151
+ <template>
152
+ <div class="rounded-s"></div>
153
+ </template>
154
+ `,
155
+ errors: [{ messageId: 'updateTailwindClass' }],
156
+ },
157
+ {
158
+ name: 'Replaces border radius utilities',
159
+ code: `
160
+ <template>
161
+ <div class="rounded-sm rounded-md rounded-lg"></div>
162
+ </template>
163
+ `,
164
+ output: `
165
+ <template>
166
+ <div class="rounded-xs rounded-m rounded-l"></div>
167
+ </template>
168
+ `,
169
+ errors: [{ messageId: 'updateTailwindClass' }],
170
+ },
171
+ // Border width
172
+ {
173
+ name: 'Replaces bare border',
174
+ code: `
175
+ <template>
176
+ <div class="border"></div>
177
+ </template>
178
+ `,
179
+ output: `
180
+ <template>
181
+ <div class="border-s"></div>
182
+ </template>
183
+ `,
184
+ errors: [{ messageId: 'updateTailwindClass' }],
185
+ },
186
+ {
187
+ name: 'Replaces border width utilities',
188
+ code: `
189
+ <template>
190
+ <div class="border-2 border-4"></div>
191
+ </template>
192
+ `,
193
+ output: `
194
+ <template>
195
+ <div class="border-m border-l"></div>
196
+ </template>
197
+ `,
198
+ errors: [{ messageId: 'updateTailwindClass' }],
199
+ },
200
+ // Box shadows
201
+ {
202
+ name: 'Replaces bare shadow',
203
+ code: `
204
+ <template>
205
+ <div class="shadow"></div>
206
+ </template>
207
+ `,
208
+ output: `
209
+ <template>
210
+ <div class="shadow-medium"></div>
211
+ </template>
212
+ `,
213
+ errors: [{ messageId: 'updateTailwindClass' }],
214
+ },
215
+ {
216
+ name: 'Replaces shadow utilities',
217
+ code: `
218
+ <template>
219
+ <div class="shadow-sm shadow-lg shadow-xl"></div>
220
+ </template>
221
+ `,
222
+ output: `
223
+ <template>
224
+ <div class="shadow-low shadow-high shadow-high"></div>
225
+ </template>
226
+ `,
227
+ errors: [{ messageId: 'updateTailwindClass' }],
228
+ },
229
+ // Breakpoints
230
+ {
231
+ name: 'Replaces breakpoint prefixes',
232
+ code: `
233
+ <template>
234
+ <div class="sm:p-4 lg:p-8 2xl:m-2"></div>
235
+ </template>
236
+ `,
237
+ output: `
238
+ <template>
239
+ <div class="s-min:p-070 l-min:p-100 xxl:m-040"></div>
240
+ </template>
241
+ `,
242
+ errors: [{ messageId: 'updateTailwindClass' }],
243
+ },
244
+ {
245
+ name: 'Replaces breakpoint with non-spacing class',
246
+ code: `
247
+ <template>
248
+ <div class="md:rounded-md xl:shadow-lg"></div>
249
+ </template>
250
+ `,
251
+ output: `
252
+ <template>
253
+ <div class="m-min:rounded-m xl-min:shadow-high"></div>
254
+ </template>
255
+ `,
256
+ errors: [{ messageId: 'updateTailwindClass' }],
257
+ },
258
+ {
259
+ name: 'Handles combined breakpoint and other variants',
260
+ code: `
261
+ <template>
262
+ <div class="md:hover:p-4 lg:focus:rounded-md"></div>
263
+ </template>
264
+ `,
265
+ output: `
266
+ <template>
267
+ <div class="m-min:hover:p-070 l-min:focus:rounded-m"></div>
268
+ </template>
269
+ `,
270
+ errors: [{ messageId: 'updateTailwindClass' }],
271
+ },
272
+ {
273
+ name: 'Preserves non-breakpoint variants without replacing',
274
+ code: `
275
+ <template>
276
+ <div class="hover:bg-gray-100 focus:border-gray-300"></div>
277
+ </template>
278
+ `,
279
+ output: `
280
+ <template>
281
+ <div class="hover:bg-secondary focus:border-outline-primary"></div>
282
+ </template>
283
+ `,
284
+ errors: [{ messageId: 'updateTailwindClass' }],
285
+ },
286
+ // Colors
287
+ {
288
+ name: 'Replaces text color utilities',
289
+ code: `
290
+ <template>
291
+ <div class="text-red-500 text-green-500 text-blue-500"></div>
292
+ </template>
293
+ `,
294
+ output: `
295
+ <template>
296
+ <div class="text-label-critical text-label-success text-label-highlight"></div>
297
+ </template>
298
+ `,
299
+ errors: [{ messageId: 'updateTailwindClass' }],
300
+ },
301
+ {
302
+ name: 'Replaces background color utilities',
303
+ code: `
304
+ <template>
305
+ <div class="bg-gray-100 bg-red-100 bg-green-100"></div>
306
+ </template>
307
+ `,
308
+ output: `
309
+ <template>
310
+ <div class="bg-secondary bg-critical-weak bg-success-weak"></div>
311
+ </template>
312
+ `,
313
+ errors: [{ messageId: 'updateTailwindClass' }],
314
+ },
315
+ {
316
+ name: 'Replaces border color utilities',
317
+ code: `
318
+ <template>
319
+ <div class="border-gray-300"></div>
320
+ </template>
321
+ `,
322
+ output: `
323
+ <template>
324
+ <div class="border-outline-primary"></div>
325
+ </template>
326
+ `,
327
+ errors: [{ messageId: 'updateTailwindClass' }],
328
+ },
329
+ // Typography (warn only, no fix)
330
+ {
331
+ name: 'Warns about typography with suggested variant',
332
+ code: `
333
+ <template>
334
+ <div class="text-sm font-bold leading-tight tracking-wide"></div>
335
+ </template>
336
+ `,
337
+ errors: [
338
+ {
339
+ message:
340
+ 'Found Tailwind v3 typography class "text-sm, font-bold, leading-tight, tracking-wide" — use <bento-typography variant="caption" strongest> instead.',
341
+ },
342
+ ],
343
+ },
344
+ {
345
+ name: 'Suggests body variant for text-base',
346
+ code: `
347
+ <template>
348
+ <div class="text-base font-medium"></div>
349
+ </template>
350
+ `,
351
+ errors: [
352
+ {
353
+ message:
354
+ 'Found Tailwind v3 typography class "text-base, font-medium" — use <bento-typography variant="body" stronger> instead.',
355
+ },
356
+ ],
357
+ },
358
+ {
359
+ name: 'Suggests title variant with large for text-2xl',
360
+ code: `
361
+ <template>
362
+ <div class="text-2xl font-semibold"></div>
363
+ </template>
364
+ `,
365
+ errors: [
366
+ {
367
+ message:
368
+ 'Found Tailwind v3 typography class "text-2xl, font-semibold" — use <bento-typography variant="title" large stronger> instead.',
369
+ },
370
+ ],
371
+ },
372
+ {
373
+ name: 'Suggests title medium for text-xl',
374
+ code: `
375
+ <template>
376
+ <div class="text-xl"></div>
377
+ </template>
378
+ `,
379
+ errors: [
380
+ {
381
+ message:
382
+ 'Found Tailwind v3 typography class "text-xl" — use <bento-typography variant="title" medium> instead.',
383
+ },
384
+ ],
385
+ },
386
+ // Mixed
387
+ {
388
+ name: 'Handles mixed classes',
389
+ code: `
390
+ <template>
391
+ <div class="p-4 rounded-md shadow-lg sm:p-8 text-red-500"></div>
392
+ </template>
393
+ `,
394
+ output: `
395
+ <template>
396
+ <div class="p-070 rounded-m shadow-high s-min:p-100 text-label-critical"></div>
397
+ </template>
398
+ `,
399
+ errors: [{ messageId: 'updateTailwindClass' }],
400
+ },
401
+ ],
402
+ });
403
+ });