@kong/design-tokens 1.22.1 → 1.22.2-pr.656.bf3c7d3.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.
@@ -0,0 +1,136 @@
1
+ # @kong/design-tokens/eslint-plugin
2
+
3
+ [ESLint](https://eslint.org/) plugin for enforcing correct usage of KUI design tokens in Vue 3 (and Nuxt 4) templates.
4
+
5
+ - [Usage](#usage)
6
+ - [Rules](#rules)
7
+ - [`token-constant-requires-css-var`](#token-constant-requires-css-var)
8
+
9
+ ## Usage
10
+
11
+ Install `@kong/design-tokens`, `eslint`, and `vue-eslint-parser` (typically provided by `eslint-plugin-vue`) as `devDependencies`.
12
+
13
+ ```sh
14
+ pnpm add -D @kong/design-tokens eslint eslint-plugin-vue
15
+ ```
16
+
17
+ In your `eslint.config.mjs` (ESLint 9 flat config):
18
+
19
+ ```js
20
+ import vue from 'eslint-plugin-vue'
21
+ import designTokens from '@kong/design-tokens/eslint-plugin'
22
+
23
+ export default [
24
+ // your existing vue config...
25
+ ...vue.configs['flat/recommended'],
26
+
27
+ // apply the design tokens plugin
28
+ {
29
+ files: ['**/*.vue'],
30
+ plugins: {
31
+ '@kong/design-tokens': designTokens,
32
+ },
33
+ rules: {
34
+ '@kong/design-tokens/token-constant-requires-css-var': 'error',
35
+ },
36
+ },
37
+ ]
38
+ ```
39
+
40
+ Or use the shipped `configs.recommended` shorthand:
41
+
42
+ ```js
43
+ import vue from 'eslint-plugin-vue'
44
+ import designTokens from '@kong/design-tokens/eslint-plugin'
45
+
46
+ export default [
47
+ ...vue.configs['flat/recommended'],
48
+ {
49
+ files: ['**/*.vue'],
50
+ ...designTokens.configs.recommended,
51
+ },
52
+ ]
53
+ ```
54
+
55
+ Auto-fix with:
56
+
57
+ ```sh
58
+ eslint --fix src/
59
+ ```
60
+
61
+ ## Rules
62
+
63
+ ### `token-constant-requires-css-var`
64
+
65
+ Enforces that KUI design token constants (imported from `@kong/design-tokens`) are wrapped in a CSS custom property fallback whenever they are used in a Vue `<template>` v-bind expression.
66
+
67
+ The CSS custom property **must come first** so that DOM-level overrides (light/dark mode, theming) take effect. The JS constant serves as the static fallback.
68
+
69
+ #### :red_circle: Incorrect usage
70
+
71
+ ```vue
72
+ <template>
73
+ <!-- ❌ bare token constant — DOM overrides cannot work -->
74
+ <MyComponent :color="KUI_COLOR_TEXT_INVERSE" />
75
+
76
+ <!-- ❌ ternary branches -->
77
+ <MyComponent :color="isDark ? KUI_COLOR_TEXT_INVERSE : KUI_COLOR_TEXT_PRIMARY" />
78
+
79
+ <!-- ❌ object shorthand in :style -->
80
+ <MyComponent :style="{ color: KUI_COLOR_TEXT_INVERSE }" />
81
+ </template>
82
+
83
+ <script setup>
84
+ import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_TEXT_PRIMARY } from '@kong/design-tokens'
85
+ </script>
86
+ ```
87
+
88
+ #### :green_circle: Correct usage (auto-fixed)
89
+
90
+ ```vue
91
+ <template>
92
+ <!-- ✅ CSS custom property first, JS constant as fallback -->
93
+ <MyComponent :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />
94
+
95
+ <!-- ✅ both ternary branches wrapped -->
96
+ <MyComponent :color="isDark
97
+ ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`
98
+ : `var(--kui-color-text-primary, ${KUI_COLOR_TEXT_PRIMARY})`" />
99
+
100
+ <!-- ✅ object property value wrapped -->
101
+ <MyComponent :style="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` }" />
102
+ </template>
103
+
104
+ <script setup>
105
+ import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_TEXT_PRIMARY } from '@kong/design-tokens'
106
+ </script>
107
+ ```
108
+
109
+ #### Report-only cases (no autofix)
110
+
111
+ The following cases are flagged but not auto-fixed because rewriting them would change semantics or be unsafe:
112
+
113
+ - **Template literals**: `:color="\`${KUI_X}\`"` — nesting backtick strings requires manual rewrite.
114
+ - **Binary expressions**: `:color="KUI_X + '!important'"` — string concatenation.
115
+ - **Function arguments**: `:color="darken(KUI_X)"` — the function may not accept a CSS `var()` string.
116
+ - **Script-setup variables**: `` const c = KUI_X; `` then `:color="c"` — fixing the declaration would affect all consumers of the variable. Wrap at the template binding site instead.
117
+
118
+ #### Token naming
119
+
120
+ The CSS custom property name is derived from the JS constant by lowercasing and replacing underscores with hyphens:
121
+
122
+ ```
123
+ KUI_COLOR_TEXT_INVERSE → --kui-color-text-inverse
124
+ KUI_SPACE_40 → --kui-space-40
125
+ KUI_FONT_SIZE_30 → --kui-font-size-30
126
+ ```
127
+
128
+ #### Limitations
129
+
130
+ - **Namespace imports** (`import * as tokens from '@kong/design-tokens'`) are not tracked.
131
+ - **Re-exports through barrel files** in consumer code — only direct `@kong/design-tokens` imports are detected.
132
+ - **Multi-hop script-setup flow** (`const a = KUI_X; const b = a`) — only one level of indirection is detected.
133
+ - **Render functions / JSX** (`.tsx` SFC blocks) — template-only.
134
+ - **`<style>` blocks** — use [`@kong/design-tokens/stylelint-plugin`](./stylelint-plugin/README.md) instead.
135
+
136
+ > **Note**: KUI design token JS constants resolve to primitive strings (e.g. `'#1456cb'`). When wrapped in `` `var(--kui-x, ${KUI_X})` ``, the fallback is that primitive string. In production Kong apps the CSS custom properties are always loaded, so the fallback fires only if the stylesheet fails to load.
@@ -0,0 +1,36 @@
1
+ import tokenConstantRequiresCssVar from './rules/token-constant-requires-css-var/index.mjs'
2
+
3
+ /**
4
+ * ESLint plugin that enforces correct usage of Kong design tokens in Vue 3
5
+ * template `v-bind` expressions. Tokens imported from `@kong/design-tokens`
6
+ * must be wrapped in a CSS custom property fallback so DOM-level theme
7
+ * overrides (light/dark mode) take effect.
8
+ */
9
+ const plugin = {
10
+ meta: {
11
+ name: '@kong/design-tokens/eslint-plugin',
12
+ },
13
+ rules: {
14
+ 'token-constant-requires-css-var': tokenConstantRequiresCssVar,
15
+ },
16
+ // configs is assigned below to allow self-reference inside the object
17
+ configs: /** @type {any} */ (undefined),
18
+ }
19
+
20
+ plugin.configs = {
21
+ /**
22
+ * Flat-config preset that registers the plugin and enables the rule as an
23
+ * error. Spread into your `eslint.config.mjs` inside a `files: ['**\/*.vue']`
24
+ * config entry.
25
+ */
26
+ recommended: {
27
+ plugins: {
28
+ '@kong/design-tokens': plugin,
29
+ },
30
+ rules: {
31
+ '@kong/design-tokens/token-constant-requires-css-var': 'error',
32
+ },
33
+ },
34
+ }
35
+
36
+ export default plugin
@@ -0,0 +1,558 @@
1
+ import { describe, it } from 'vitest'
2
+ import { RuleTester } from 'eslint'
3
+ import vueParser from 'vue-eslint-parser'
4
+ import rule from '../index.mjs'
5
+
6
+ // Wire up vitest's describe/it so RuleTester integrates with the vitest reporter
7
+ RuleTester.describe = describe
8
+ RuleTester.it = it
9
+ RuleTester.itOnly = it
10
+
11
+ const tester = new RuleTester({
12
+ languageOptions: {
13
+ parser: vueParser,
14
+ parserOptions: {
15
+ ecmaVersion: 2020,
16
+ sourceType: 'module',
17
+ },
18
+ },
19
+ })
20
+
21
+ const RULE_NAME = '@kong/design-tokens/token-constant-requires-css-var'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // SFC source helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Builds a minimal `<script setup>` + `<template>` SFC string. */
28
+ function sfc({ script = '', template = '' } = {}) {
29
+ return [
30
+ '<script setup>',
31
+ script.trim(),
32
+ '</script>',
33
+ '<template>',
34
+ template.trim(),
35
+ '</template>',
36
+ ].join('\n')
37
+ }
38
+
39
+ /**
40
+ * Shorthand for an SFC that imports one token from `@kong/design-tokens`.
41
+ * @param {string} varName - The exported constant name (e.g. `KUI_COLOR_TEXT_INVERSE`)
42
+ * @param {string} template - The `<template>` body
43
+ * @param {string} [alias] - Optional local alias (`import { varName as alias }`)
44
+ */
45
+ function withImport(varName, template, alias) {
46
+ const specifier = alias ? `${varName} as ${alias}` : varName
47
+ return sfc({
48
+ script: `import { ${specifier} } from '@kong/design-tokens'`,
49
+ template,
50
+ })
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Valid cases — must produce zero errors
55
+ // ---------------------------------------------------------------------------
56
+ tester.run(RULE_NAME, rule, {
57
+ valid: [
58
+ // Already properly wrapped — idempotency check
59
+ {
60
+ filename: 'test.vue',
61
+ code: withImport(
62
+ 'KUI_COLOR_TEXT_INVERSE',
63
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
64
+ ),
65
+ },
66
+
67
+ // Already wrapped inside a ternary branch
68
+ {
69
+ filename: 'test.vue',
70
+ code: withImport(
71
+ 'KUI_COLOR_TEXT_INVERSE',
72
+ "<div :color=\"cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : 'red'\" />",
73
+ ),
74
+ },
75
+
76
+ // Identifier not imported from @kong/design-tokens
77
+ {
78
+ filename: 'test.vue',
79
+ code: sfc({
80
+ script: "const myColor = '#fff'",
81
+ template: '<div :color="myColor" />',
82
+ }),
83
+ },
84
+
85
+ // Import from a different package — not tracked
86
+ {
87
+ filename: 'test.vue',
88
+ code: sfc({
89
+ script: "import { KUI_COLOR_TEXT_INVERSE } from 'other-package'",
90
+ template: '<div :color="KUI_COLOR_TEXT_INVERSE" />',
91
+ }),
92
+ },
93
+
94
+ // KUI token in v-if — not a v-bind style value
95
+ {
96
+ filename: 'test.vue',
97
+ code: withImport(
98
+ 'KUI_COLOR_TEXT_INVERSE',
99
+ '<div v-if="KUI_COLOR_TEXT_INVERSE" />',
100
+ ),
101
+ },
102
+
103
+ // KUI token in mustache interpolation — not a v-bind
104
+ {
105
+ filename: 'test.vue',
106
+ code: withImport(
107
+ 'KUI_COLOR_TEXT_INVERSE',
108
+ '<div>{{ KUI_COLOR_TEXT_INVERSE }}</div>',
109
+ ),
110
+ },
111
+
112
+ // Namespace import — not individually tracked
113
+ {
114
+ filename: 'test.vue',
115
+ code: sfc({
116
+ script: "import * as tokens from '@kong/design-tokens'",
117
+ template: '<div :color="tokens.KUI_COLOR_TEXT_INVERSE" />',
118
+ }),
119
+ },
120
+
121
+ // Static (non-binding) attribute — no colon
122
+ {
123
+ filename: 'test.vue',
124
+ code: withImport(
125
+ 'KUI_COLOR_TEXT_INVERSE',
126
+ '<div color="someStaticValue" />',
127
+ ),
128
+ },
129
+
130
+ // v-on directive — not a v-bind, never visited by the rule
131
+ {
132
+ filename: 'test.vue',
133
+ code: withImport(
134
+ 'KUI_COLOR_TEXT_INVERSE',
135
+ '<div @click="handler(KUI_COLOR_TEXT_INVERSE)" />',
136
+ ),
137
+ },
138
+
139
+ // MemberExpression property name — KUI token is the key, not the value reference
140
+ // `walkExpression` only walks the object side of a MemberExpression, not the property.
141
+ {
142
+ filename: 'test.vue',
143
+ code: withImport(
144
+ 'KUI_COLOR_TEXT_INVERSE',
145
+ '<div :color="theme.KUI_COLOR_TEXT_INVERSE" />',
146
+ ),
147
+ },
148
+
149
+ // Partially wrapped ternary — the already-wrapped branch is idempotent (no re-report)
150
+ {
151
+ filename: 'test.vue',
152
+ code: withImport(
153
+ 'KUI_COLOR_TEXT_INVERSE',
154
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
155
+ ),
156
+ },
157
+ ],
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Invalid cases — must report errors (with or without autofix)
161
+ // ---------------------------------------------------------------------------
162
+ invalid: [
163
+ // Simple: bare identifier as the whole binding expression
164
+ {
165
+ filename: 'test.vue',
166
+ code: withImport(
167
+ 'KUI_COLOR_TEXT_INVERSE',
168
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
169
+ ),
170
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
171
+ output: withImport(
172
+ 'KUI_COLOR_TEXT_INVERSE',
173
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
174
+ ),
175
+ },
176
+
177
+ // Import alias: CSS var uses canonical name, fallback uses local alias
178
+ {
179
+ filename: 'test.vue',
180
+ code: withImport(
181
+ 'KUI_COLOR_TEXT_INVERSE',
182
+ '<div :color="myColor" />',
183
+ 'myColor',
184
+ ),
185
+ errors: [{ messageId: 'wrapInVar', data: { local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
186
+ output: withImport(
187
+ 'KUI_COLOR_TEXT_INVERSE',
188
+ '<div :color="`var(--kui-color-text-inverse, ${myColor})`" />',
189
+ 'myColor',
190
+ ),
191
+ },
192
+
193
+ // Ternary: both branches are the same KUI token — two separate fixes
194
+ {
195
+ filename: 'test.vue',
196
+ code: withImport(
197
+ 'KUI_COLOR_TEXT_INVERSE',
198
+ '<div :color="cond ? KUI_COLOR_TEXT_INVERSE : KUI_COLOR_TEXT_INVERSE" />',
199
+ ),
200
+ errors: [{ messageId: 'wrapInVar' }, { messageId: 'wrapInVar' }],
201
+ output: withImport(
202
+ 'KUI_COLOR_TEXT_INVERSE',
203
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
204
+ ),
205
+ },
206
+
207
+ // Ternary: two DIFFERENT KUI tokens in consequent and alternate — independent CSS vars
208
+ {
209
+ filename: 'test.vue',
210
+ code: sfc({
211
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
212
+ template: '<div :color="cond ? KUI_COLOR_TEXT_INVERSE : KUI_COLOR_BACKGROUND_PRIMARY" />',
213
+ }),
214
+ errors: [
215
+ { messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } },
216
+ { messageId: 'wrapInVar', data: { local: 'KUI_COLOR_BACKGROUND_PRIMARY', cssVar: 'kui-color-background-primary' } },
217
+ ],
218
+ output: sfc({
219
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
220
+ template: '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-background-primary, ${KUI_COLOR_BACKGROUND_PRIMARY})`" />',
221
+ }),
222
+ },
223
+
224
+ // Partially wrapped ternary: only the unwrapped branch is reported and fixed
225
+ {
226
+ filename: 'test.vue',
227
+ code: withImport(
228
+ 'KUI_COLOR_TEXT_INVERSE',
229
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : KUI_COLOR_TEXT_INVERSE" />',
230
+ ),
231
+ errors: [{ messageId: 'wrapInVar' }],
232
+ output: withImport(
233
+ 'KUI_COLOR_TEXT_INVERSE',
234
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
235
+ ),
236
+ },
237
+
238
+ // Object: `:style="{ color: KUI_X }"`
239
+ {
240
+ filename: 'test.vue',
241
+ code: withImport(
242
+ 'KUI_COLOR_TEXT_INVERSE',
243
+ '<div :style="{ color: KUI_COLOR_TEXT_INVERSE }" />',
244
+ ),
245
+ errors: [{ messageId: 'wrapInVar' }],
246
+ output: withImport(
247
+ 'KUI_COLOR_TEXT_INVERSE',
248
+ '<div :style="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` }" />',
249
+ ),
250
+ },
251
+
252
+ // Object with two KUI values — both fixed in a single pass
253
+ {
254
+ filename: 'test.vue',
255
+ code: sfc({
256
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
257
+ template: '<div :style="{ color: KUI_COLOR_TEXT_INVERSE, background: KUI_COLOR_BACKGROUND_PRIMARY }" />',
258
+ }),
259
+ errors: [{ messageId: 'wrapInVar' }, { messageId: 'wrapInVar' }],
260
+ output: sfc({
261
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
262
+ template: '<div :style="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`, background: `var(--kui-color-background-primary, ${KUI_COLOR_BACKGROUND_PRIMARY})` }" />',
263
+ }),
264
+ },
265
+
266
+ // Array element
267
+ {
268
+ filename: 'test.vue',
269
+ code: withImport(
270
+ 'KUI_COLOR_TEXT_INVERSE',
271
+ '<div :style="[baseStyle, KUI_COLOR_TEXT_INVERSE]" />',
272
+ ),
273
+ errors: [{ messageId: 'wrapInVar' }],
274
+ output: withImport(
275
+ 'KUI_COLOR_TEXT_INVERSE',
276
+ '<div :style="[baseStyle, `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`]" />',
277
+ ),
278
+ },
279
+
280
+ // LogicalExpression (??) — autofix the right-hand operand
281
+ {
282
+ filename: 'test.vue',
283
+ code: withImport(
284
+ 'KUI_COLOR_TEXT_INVERSE',
285
+ '<div :color="theme.color ?? KUI_COLOR_TEXT_INVERSE" />',
286
+ ),
287
+ errors: [{ messageId: 'wrapInVar' }],
288
+ output: withImport(
289
+ 'KUI_COLOR_TEXT_INVERSE',
290
+ '<div :color="theme.color ?? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
291
+ ),
292
+ },
293
+
294
+ // LogicalExpression (||) — autofix the right-hand operand
295
+ {
296
+ filename: 'test.vue',
297
+ code: withImport(
298
+ 'KUI_COLOR_TEXT_INVERSE',
299
+ '<div :color="theme.color || KUI_COLOR_TEXT_INVERSE" />',
300
+ ),
301
+ errors: [{ messageId: 'wrapInVar' }],
302
+ output: withImport(
303
+ 'KUI_COLOR_TEXT_INVERSE',
304
+ '<div :color="theme.color || `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
305
+ ),
306
+ },
307
+
308
+ // Dynamic argument (:[propName])
309
+ {
310
+ filename: 'test.vue',
311
+ code: withImport(
312
+ 'KUI_COLOR_TEXT_INVERSE',
313
+ '<div :[propName]="KUI_COLOR_TEXT_INVERSE" />',
314
+ ),
315
+ errors: [{ messageId: 'wrapInVar' }],
316
+ output: withImport(
317
+ 'KUI_COLOR_TEXT_INVERSE',
318
+ '<div :[propName]="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
319
+ ),
320
+ },
321
+
322
+ // v-bind without argument (object spread syntax)
323
+ {
324
+ filename: 'test.vue',
325
+ code: withImport(
326
+ 'KUI_COLOR_TEXT_INVERSE',
327
+ '<div v-bind="{ color: KUI_COLOR_TEXT_INVERSE }" />',
328
+ ),
329
+ errors: [{ messageId: 'wrapInVar' }],
330
+ output: withImport(
331
+ 'KUI_COLOR_TEXT_INVERSE',
332
+ '<div v-bind="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` }" />',
333
+ ),
334
+ },
335
+
336
+ // SequenceExpression — last element gets AUTOFIX context; earlier ones are REPORT_ONLY
337
+ {
338
+ filename: 'test.vue',
339
+ code: withImport(
340
+ 'KUI_COLOR_TEXT_INVERSE',
341
+ '<div :color="(sideEffect(), KUI_COLOR_TEXT_INVERSE)" />',
342
+ ),
343
+ errors: [{ messageId: 'wrapInVar' }],
344
+ output: withImport(
345
+ 'KUI_COLOR_TEXT_INVERSE',
346
+ '<div :color="(sideEffect(), `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`)" />',
347
+ ),
348
+ },
349
+
350
+ // AssignmentExpression — always REPORT_ONLY (wrapping the assigned value changes semantics)
351
+ {
352
+ filename: 'test.vue',
353
+ code: withImport(
354
+ 'KUI_COLOR_TEXT_INVERSE',
355
+ '<div :color="(x = KUI_COLOR_TEXT_INVERSE)" />',
356
+ ),
357
+ errors: [{ messageId: 'wrapInVarNoFix' }],
358
+ output: null,
359
+ },
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Token family coverage — same autofix transform applies to all KUI_ prefixes
363
+ // ---------------------------------------------------------------------------
364
+
365
+ // KUI_FONT_SIZE — typography scale tokens
366
+ {
367
+ filename: 'test.vue',
368
+ code: withImport(
369
+ 'KUI_FONT_SIZE_30',
370
+ '<div :style="{ fontSize: KUI_FONT_SIZE_30 }" />',
371
+ ),
372
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_FONT_SIZE_30', cssVar: 'kui-font-size-30' } }],
373
+ output: withImport(
374
+ 'KUI_FONT_SIZE_30',
375
+ '<div :style="{ fontSize: `var(--kui-font-size-30, ${KUI_FONT_SIZE_30})` }" />',
376
+ ),
377
+ },
378
+
379
+ // KUI_BORDER_RADIUS — shape tokens
380
+ {
381
+ filename: 'test.vue',
382
+ code: withImport(
383
+ 'KUI_BORDER_RADIUS_20',
384
+ '<div :style="{ borderRadius: KUI_BORDER_RADIUS_20 }" />',
385
+ ),
386
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_BORDER_RADIUS_20', cssVar: 'kui-border-radius-20' } }],
387
+ output: withImport(
388
+ 'KUI_BORDER_RADIUS_20',
389
+ '<div :style="{ borderRadius: `var(--kui-border-radius-20, ${KUI_BORDER_RADIUS_20})` }" />',
390
+ ),
391
+ },
392
+
393
+ // KUI_FONT_WEIGHT — weight scale tokens
394
+ {
395
+ filename: 'test.vue',
396
+ code: withImport(
397
+ 'KUI_FONT_WEIGHT_BOLD',
398
+ '<div :style="{ fontWeight: KUI_FONT_WEIGHT_BOLD }" />',
399
+ ),
400
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_FONT_WEIGHT_BOLD', cssVar: 'kui-font-weight-bold' } }],
401
+ output: withImport(
402
+ 'KUI_FONT_WEIGHT_BOLD',
403
+ '<div :style="{ fontWeight: `var(--kui-font-weight-bold, ${KUI_FONT_WEIGHT_BOLD})` }" />',
404
+ ),
405
+ },
406
+
407
+ // KUI_LINE_HEIGHT — line height tokens
408
+ {
409
+ filename: 'test.vue',
410
+ code: withImport(
411
+ 'KUI_LINE_HEIGHT_40',
412
+ '<div :style="{ lineHeight: KUI_LINE_HEIGHT_40 }" />',
413
+ ),
414
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_LINE_HEIGHT_40', cssVar: 'kui-line-height-40' } }],
415
+ output: withImport(
416
+ 'KUI_LINE_HEIGHT_40',
417
+ '<div :style="{ lineHeight: `var(--kui-line-height-40, ${KUI_LINE_HEIGHT_40})` }" />',
418
+ ),
419
+ },
420
+
421
+ // KUI_Z_INDEX — z-index tokens
422
+ {
423
+ filename: 'test.vue',
424
+ code: withImport(
425
+ 'KUI_Z_INDEX_10',
426
+ '<div :style="{ zIndex: KUI_Z_INDEX_10 }" />',
427
+ ),
428
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_Z_INDEX_10', cssVar: 'kui-z-index-10' } }],
429
+ output: withImport(
430
+ 'KUI_Z_INDEX_10',
431
+ '<div :style="{ zIndex: `var(--kui-z-index-10, ${KUI_Z_INDEX_10})` }" />',
432
+ ),
433
+ },
434
+
435
+ // KUI_SPACE — spacing tokens (also confirms numeric suffix handling)
436
+ {
437
+ filename: 'test.vue',
438
+ code: withImport(
439
+ 'KUI_SPACE_40',
440
+ '<div :style="{ padding: KUI_SPACE_40 }" />',
441
+ ),
442
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_SPACE_40', cssVar: 'kui-space-40' } }],
443
+ output: withImport(
444
+ 'KUI_SPACE_40',
445
+ '<div :style="{ padding: `var(--kui-space-40, ${KUI_SPACE_40})` }" />',
446
+ ),
447
+ },
448
+
449
+ // KUI_BREAKPOINT — breakpoint tokens (word-only suffix, no numbers)
450
+ {
451
+ filename: 'test.vue',
452
+ code: withImport(
453
+ 'KUI_BREAKPOINT_PHABLET',
454
+ '<div :style="{ maxWidth: KUI_BREAKPOINT_PHABLET }" />',
455
+ ),
456
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_BREAKPOINT_PHABLET', cssVar: 'kui-breakpoint-phablet' } }],
457
+ output: withImport(
458
+ 'KUI_BREAKPOINT_PHABLET',
459
+ '<div :style="{ maxWidth: `var(--kui-breakpoint-phablet, ${KUI_BREAKPOINT_PHABLET})` }" />',
460
+ ),
461
+ },
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // REPORT_ONLY — no autofix because rewriting would change expression semantics
465
+ // ---------------------------------------------------------------------------
466
+
467
+ // Inside TemplateLiteral (no autofix: would nest backticks)
468
+ {
469
+ filename: 'test.vue',
470
+ code: withImport(
471
+ 'KUI_COLOR_TEXT_INVERSE',
472
+ '<div :color="`${KUI_COLOR_TEXT_INVERSE}`" />',
473
+ ),
474
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
475
+ output: null,
476
+ },
477
+
478
+ // BinaryExpression (no autofix: changes string semantics)
479
+ {
480
+ filename: 'test.vue',
481
+ code: withImport(
482
+ 'KUI_COLOR_TEXT_INVERSE',
483
+ "<div :color=\"KUI_COLOR_TEXT_INVERSE + '!important'\" />",
484
+ ),
485
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
486
+ output: null,
487
+ },
488
+
489
+ // CallExpression argument (no autofix: could break color helpers like darken/rgba)
490
+ {
491
+ filename: 'test.vue',
492
+ code: withImport(
493
+ 'KUI_COLOR_TEXT_INVERSE',
494
+ '<div :color="darken(KUI_COLOR_TEXT_INVERSE)" />',
495
+ ),
496
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
497
+ output: null,
498
+ },
499
+
500
+ // Script-setup variable (`const c = KUI_X`) — detected, no autofix
501
+ {
502
+ filename: 'test.vue',
503
+ code: sfc({
504
+ script: [
505
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
506
+ 'const myColor = KUI_COLOR_TEXT_INVERSE',
507
+ ].join('\n'),
508
+ template: '<div :color="myColor" />',
509
+ }),
510
+ errors: [{ messageId: 'wrapInVarScriptSetup', data: { imported: 'KUI_COLOR_TEXT_INVERSE', local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
511
+ output: null,
512
+ },
513
+ ],
514
+ })
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Idempotency: the fixer output must itself be a valid (no-error) input.
518
+ // tester.run() must be called at the top level (not inside it()) because it
519
+ // internally calls describe() which vitest forbids inside test blocks.
520
+ // ---------------------------------------------------------------------------
521
+ tester.run(`${RULE_NAME} (idempotency)`, rule, {
522
+ valid: [
523
+ {
524
+ filename: 'test.vue',
525
+ name: 'does not re-report already-fixed color token',
526
+ code: withImport(
527
+ 'KUI_COLOR_TEXT_INVERSE',
528
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
529
+ ),
530
+ },
531
+ {
532
+ filename: 'test.vue',
533
+ name: 'does not re-report already-fixed font-size token',
534
+ code: withImport(
535
+ 'KUI_FONT_SIZE_30',
536
+ '<div :style="{ fontSize: `var(--kui-font-size-30, ${KUI_FONT_SIZE_30})` }" />',
537
+ ),
538
+ },
539
+ {
540
+ filename: 'test.vue',
541
+ name: 'does not re-report already-fixed space token inside object',
542
+ code: sfc({
543
+ script: "import { KUI_SPACE_40, KUI_BORDER_RADIUS_20 } from '@kong/design-tokens'",
544
+ template: '<div :style="{ padding: `var(--kui-space-40, ${KUI_SPACE_40})`, borderRadius: `var(--kui-border-radius-20, ${KUI_BORDER_RADIUS_20})` }" />',
545
+ }),
546
+ },
547
+ ],
548
+ invalid: [],
549
+ })
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // TypeScript note
553
+ // ---------------------------------------------------------------------------
554
+ // `import type { KUI_X }` (type-only imports with no runtime value) are skipped
555
+ // by the rule via `importKind === 'type'`. Testing that branch requires
556
+ // @typescript-eslint/parser as the script-block parser for vue-eslint-parser,
557
+ // which would add an extra devDependency. That coverage is left to integration
558
+ // tests in the consumer project.
@@ -0,0 +1,328 @@
1
+ import {
2
+ KUI_IDENTIFIER_PATTERN,
3
+ KUI_IMPORT_SOURCE,
4
+ kuiIdentifierToCssVar,
5
+ } from '../../utilities/index.mjs'
6
+
7
+ /** Identifier can be safely auto-fixed in this expression position. */
8
+ const AUTOFIX = 'fix'
9
+
10
+ /** Identifier must be reported but cannot be auto-fixed without changing semantics. */
11
+ const REPORT_ONLY = 'report'
12
+
13
+ /**
14
+ * Returns `true` when the TemplateLiteral expression slot at `exprIndex` is
15
+ * already in the form `` `var(--kui-..., ${IDENTIFIER})` ``, so we can skip
16
+ * it on subsequent lint passes (idempotency).
17
+ *
18
+ * @param {import('estree').TemplateLiteral} templateNode
19
+ * @param {number} exprIndex - Index of the expression slot to inspect
20
+ * @returns {boolean}
21
+ */
22
+ function isAlreadyWrappedSlot(templateNode, exprIndex) {
23
+ const priorText = templateNode.quasis[exprIndex]?.value?.cooked ?? ''
24
+ const nextText = templateNode.quasis[exprIndex + 1]?.value?.cooked ?? ''
25
+ return /var\(--kui-[a-z0-9-]+,\s*$/.test(priorText) && nextText.startsWith(')')
26
+ }
27
+
28
+ /**
29
+ * Recursively walks a v-bind expression tree, calling `onIdentifier` for each
30
+ * `Identifier` node that should be inspected for KUI token usage.
31
+ *
32
+ * The `ctx` parameter propagates through the tree and is downgraded from
33
+ * `AUTOFIX` to `REPORT_ONLY` when entering contexts where replacing an
34
+ * Identifier with a backtick template literal would be unsafe (e.g. inside an
35
+ * existing TemplateLiteral, a BinaryExpression, or a CallExpression argument).
36
+ *
37
+ * @param {import('estree').Node | null | undefined} node - Current AST node
38
+ * @param {string} ctx - Either `AUTOFIX` or `REPORT_ONLY`
39
+ * @param {(id: import('estree').Identifier, ctx: string) => void} onIdentifier
40
+ */
41
+ function walkExpression(node, ctx, onIdentifier) {
42
+ if (!node) return
43
+
44
+ // TypeScript-specific transparent wrappers (present when @typescript-eslint/parser
45
+ // is used for the SFC <script> block). These types are not in the estree Node union,
46
+ // so we widen to `any` before comparing to avoid an TS2367 "no overlap" error.
47
+ const nodeType = /** @type {string} */ (node.type)
48
+ if (nodeType === 'TSAsExpression' || nodeType === 'TSNonNullExpression' || nodeType === 'TSTypeAssertion') {
49
+ walkExpression(/** @type {{ expression: import('estree').Node }} */ (node).expression, ctx, onIdentifier)
50
+ return
51
+ }
52
+
53
+ switch (node.type) {
54
+ case 'Identifier':
55
+ onIdentifier(/** @type {import('estree').Identifier} */ (node), ctx)
56
+ break
57
+
58
+ case 'ConditionalExpression': {
59
+ const n = /** @type {import('estree').ConditionalExpression} */ (node)
60
+ // Walk value branches only; the boolean test is not a style value
61
+ walkExpression(n.consequent, ctx, onIdentifier)
62
+ walkExpression(n.alternate, ctx, onIdentifier)
63
+ break
64
+ }
65
+
66
+ case 'LogicalExpression': {
67
+ const n = /** @type {import('estree').LogicalExpression} */ (node)
68
+ walkExpression(n.left, ctx, onIdentifier)
69
+ walkExpression(n.right, ctx, onIdentifier)
70
+ break
71
+ }
72
+
73
+ case 'ObjectExpression': {
74
+ const n = /** @type {import('estree').ObjectExpression} */ (node)
75
+ for (const prop of n.properties) {
76
+ if (prop.type === 'Property') {
77
+ walkExpression(prop.value, ctx, onIdentifier)
78
+ }
79
+ // SpreadElement is not walked — shape is unknown at static analysis time
80
+ }
81
+ break
82
+ }
83
+
84
+ case 'ArrayExpression': {
85
+ const n = /** @type {import('estree').ArrayExpression} */ (node)
86
+ for (const el of n.elements) {
87
+ if (el) walkExpression(el, ctx, onIdentifier)
88
+ }
89
+ break
90
+ }
91
+
92
+ case 'TemplateLiteral': {
93
+ const n = /** @type {import('estree').TemplateLiteral} */ (node)
94
+ // Replacing an Identifier inside `${}` with another backtick string would
95
+ // nest backticks — not valid JS. Check idempotency first; otherwise report.
96
+ n.expressions.forEach((expr, i) => {
97
+ if (isAlreadyWrappedSlot(n, i)) return
98
+ walkExpression(expr, REPORT_ONLY, onIdentifier)
99
+ })
100
+ break
101
+ }
102
+
103
+ case 'CallExpression': {
104
+ const n = /** @type {import('estree').CallExpression} */ (node)
105
+ // Arguments are REPORT_ONLY: the function may expect a raw color value
106
+ // (e.g. darken(), rgba()) and wrapping in var() would break it.
107
+ for (const arg of n.arguments) {
108
+ walkExpression(arg, REPORT_ONLY, onIdentifier)
109
+ }
110
+ break
111
+ }
112
+
113
+ case 'BinaryExpression': {
114
+ const n = /** @type {import('estree').BinaryExpression} */ (node)
115
+ // String concat or arithmetic — wrapping changes the resulting value type
116
+ walkExpression(n.left, REPORT_ONLY, onIdentifier)
117
+ walkExpression(n.right, REPORT_ONLY, onIdentifier)
118
+ break
119
+ }
120
+
121
+ // Standard optional-chaining wrapper (e.g. `a?.b`)
122
+ case 'ChainExpression': {
123
+ const n = /** @type {import('estree').ChainExpression} */ (node)
124
+ walkExpression(n.expression, ctx, onIdentifier)
125
+ break
126
+ }
127
+
128
+ case 'AssignmentExpression': {
129
+ const n = /** @type {import('estree').AssignmentExpression} */ (node)
130
+ walkExpression(n.right, REPORT_ONLY, onIdentifier)
131
+ break
132
+ }
133
+
134
+ case 'MemberExpression': {
135
+ const n = /** @type {import('estree').MemberExpression} */ (node)
136
+ // Walk the object side only; property names are not value references
137
+ walkExpression(n.object, REPORT_ONLY, onIdentifier)
138
+ break
139
+ }
140
+
141
+ case 'SequenceExpression': {
142
+ const n = /** @type {import('estree').SequenceExpression} */ (node)
143
+ const last = n.expressions.length - 1
144
+ n.expressions.forEach((expr, i) => {
145
+ walkExpression(expr, i === last ? ctx : REPORT_ONLY, onIdentifier)
146
+ })
147
+ break
148
+ }
149
+
150
+ default:
151
+ // Unknown or unhandled node type — stop recursing
152
+ break
153
+ }
154
+ }
155
+
156
+ /** @type {import('eslint').Rule.RuleModule} */
157
+ const rule = {
158
+ meta: {
159
+ type: 'suggestion',
160
+ docs: {
161
+ description:
162
+ 'Enforce CSS custom property var() fallback for KUI design tokens in Vue template v-bind expressions',
163
+ url: 'https://github.com/Kong/design-tokens/blob/main/eslint-plugin/README.md',
164
+ },
165
+ fixable: 'code',
166
+ hasSuggestions: false,
167
+ schema: [],
168
+ messages: {
169
+ wrapInVar:
170
+ "Kong design token '{{local}}' must be wrapped in a CSS custom property fallback. " +
171
+ 'Use `var(--{{cssVar}}, ${{{local}}})` so DOM-level theme overrides (e.g., light/dark mode) take effect.',
172
+ wrapInVarNoFix:
173
+ "Kong design token '{{local}}' must be wrapped in a CSS custom property fallback, " +
174
+ 'but cannot be auto-fixed in this expression context. ' +
175
+ 'Manually change to: `var(--{{cssVar}}, ${{{local}}})` at the binding site ' +
176
+ 'so DOM-level theme overrides (e.g., light/dark mode) take effect.',
177
+ wrapInVarScriptSetup:
178
+ "Kong design token '{{imported}}' is stored in variable '{{local}}' which flows into this style binding. " +
179
+ 'Wrap the token at the template binding site: `var(--{{cssVar}}, ${{{local}}})`, or use the import directly ' +
180
+ 'so DOM-level theme overrides (e.g., light/dark mode) take effect.',
181
+ },
182
+ },
183
+
184
+ create(context) {
185
+ /**
186
+ * Maps each tracked local name to the canonical imported name.
187
+ * e.g. `import { KUI_COLOR_TEXT_INVERSE as myColor }` → `{ 'myColor' → 'KUI_COLOR_TEXT_INVERSE' }`
188
+ * @type {Map<string, string>}
189
+ */
190
+ const trackedImports = new Map()
191
+
192
+ /**
193
+ * Maps script-setup variable names to the import local name they were
194
+ * initialised from, for one-hop script detection.
195
+ * e.g. `const c = KUI_X` (where `KUI_X` is in trackedImports) → `{ 'c' → 'KUI_X' }`
196
+ * @type {Map<string, string>}
197
+ */
198
+ const trackedScriptVars = new Map()
199
+
200
+ /** @type {{ defineTemplateBodyVisitor?: Function } | undefined} */
201
+ const parserServices =
202
+ context.sourceCode?.parserServices ?? /** @type {any} */ (context).parserServices
203
+
204
+ if (!parserServices?.defineTemplateBodyVisitor) {
205
+ // vue-eslint-parser is not configured; no-op for plain JS/TS files.
206
+ // Warn when linting a .vue file so misconfigured setups are caught early.
207
+ const filename = context.filename ?? /** @type {any} */ (context).getFilename?.() ?? ''
208
+ if (filename.endsWith('.vue')) {
209
+ console.warn(
210
+ `[@kong/design-tokens] token-constant-requires-css-var: vue-eslint-parser is not active for "${filename}". ` +
211
+ 'Add vue-eslint-parser (or eslint-plugin-vue, which ships it) as the parser in your ESLint config.',
212
+ )
213
+ }
214
+ return {}
215
+ }
216
+
217
+ /**
218
+ * Reports a KUI token Identifier found inside a v-bind expression.
219
+ * Issues an autofix when `ctx === AUTOFIX`; reports without fix otherwise.
220
+ *
221
+ * @param {import('estree').Identifier} idNode - The token identifier node
222
+ * @param {string} ctx - Either `AUTOFIX` or `REPORT_ONLY`
223
+ */
224
+ function handleIdentifier(idNode, ctx) {
225
+ const localName = idNode.name
226
+
227
+ // Case 1: directly-imported KUI token (e.g. KUI_X or its local alias)
228
+ const importedName = trackedImports.get(localName)
229
+ if (importedName) {
230
+ const cssVar = kuiIdentifierToCssVar(importedName)
231
+ const cssVarNoPrefix = cssVar.slice(2) // strip leading '--' for the message
232
+
233
+ if (ctx === AUTOFIX) {
234
+ context.report({
235
+ node: idNode,
236
+ messageId: 'wrapInVar',
237
+ data: { local: localName, cssVar: cssVarNoPrefix },
238
+ fix(fixer) {
239
+ // Replace the bare Identifier with a template-literal var() fallback
240
+ return fixer.replaceText(idNode, `\`var(${cssVar}, \${${localName}})\``)
241
+ },
242
+ })
243
+ } else {
244
+ context.report({
245
+ node: idNode,
246
+ messageId: 'wrapInVarNoFix',
247
+ data: { local: localName, cssVar: cssVarNoPrefix },
248
+ })
249
+ }
250
+ return
251
+ }
252
+
253
+ // Case 2: script-setup variable initialised from a tracked KUI token
254
+ const sourceImportLocal = trackedScriptVars.get(localName)
255
+ if (!sourceImportLocal) return
256
+ const sourceImportedName = trackedImports.get(sourceImportLocal)
257
+ if (!sourceImportedName) return
258
+
259
+ const cssVar = kuiIdentifierToCssVar(sourceImportedName)
260
+ context.report({
261
+ node: idNode,
262
+ messageId: 'wrapInVarScriptSetup',
263
+ data: {
264
+ imported: sourceImportedName,
265
+ local: localName,
266
+ cssVar: cssVar.slice(2),
267
+ },
268
+ })
269
+ }
270
+
271
+ const templateVisitor = {
272
+ /** Fires for every `:prop="..."` and `v-bind="..."` attribute in the template. */
273
+ 'VAttribute[directive=true][key.name.name="bind"]'(
274
+ /** @type {any} */ node,
275
+ ) {
276
+ const valueContainer = node.value
277
+ if (!valueContainer?.expression) return
278
+ walkExpression(valueContainer.expression, AUTOFIX, handleIdentifier)
279
+ },
280
+ }
281
+
282
+ const scriptVisitor = {
283
+ /** Collects KUI_ named imports from `@kong/design-tokens`. */
284
+ ImportDeclaration(/** @type {import('estree').ImportDeclaration} */ node) {
285
+ if (node.source.value !== KUI_IMPORT_SOURCE) return
286
+ // Skip `import type { ... }` — type-only imports have no runtime value
287
+ if (/** @type {any} */ (node).importKind === 'type') return
288
+
289
+ for (const specifier of node.specifiers) {
290
+ if (specifier.type !== 'ImportSpecifier') continue
291
+ // Skip per-specifier `import { type KUI_X }` (TypeScript syntax)
292
+ if (/** @type {any} */ (specifier).importKind === 'type') continue
293
+
294
+ const imported = /** @type {import('estree').ImportSpecifier} */ (specifier).imported
295
+ const importedName = /** @type {string} */ (
296
+ 'name' in imported ? imported.name : /** @type {any} */ (imported).value
297
+ )
298
+ const localName = specifier.local.name
299
+
300
+ if (!KUI_IDENTIFIER_PATTERN.test(importedName)) continue
301
+
302
+ trackedImports.set(localName, importedName)
303
+ }
304
+ },
305
+
306
+ /**
307
+ * Detects `const c = KUI_X` in `<script setup>` (one-hop variable detection).
308
+ * Only simple `Identifier = Identifier` initialisers are tracked.
309
+ */
310
+ VariableDeclarator(/** @type {import('estree').VariableDeclarator} */ node) {
311
+ if (node.init?.type !== 'Identifier') return
312
+ if (node.id?.type !== 'Identifier') return
313
+
314
+ const initName = /** @type {import('estree').Identifier} */ (node.init).name
315
+ if (!trackedImports.has(initName)) return
316
+
317
+ trackedScriptVars.set(
318
+ /** @type {import('estree').Identifier} */ (node.id).name,
319
+ initName,
320
+ )
321
+ },
322
+ }
323
+
324
+ return parserServices.defineTemplateBodyVisitor(templateVisitor, scriptVisitor)
325
+ },
326
+ }
327
+
328
+ export default rule
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": true,
6
+ "noEmit": true,
7
+ "module": "esnext",
8
+ "moduleResolution": "bundler",
9
+ "strict": true,
10
+ "noUnusedLocals": true,
11
+ "noUnusedParameters": true
12
+ },
13
+ "include": [
14
+ "./**/*.mjs"
15
+ ],
16
+ "exclude": [
17
+ "node_modules",
18
+ "../node_modules",
19
+ "../dist"
20
+ ]
21
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Converts a `KUI_` JS constant name to its CSS custom property equivalent.
3
+ *
4
+ * @example
5
+ * kuiIdentifierToCssVar('KUI_COLOR_TEXT_INVERSE') // → '--kui-color-text-inverse'
6
+ *
7
+ * @param {string} name - A KUI token constant name (e.g. `KUI_COLOR_TEXT_INVERSE`)
8
+ * @returns {string} The corresponding CSS custom property name including the `--` prefix
9
+ */
10
+ export function kuiIdentifierToCssVar(name) {
11
+ return '--' + name.toLowerCase().replace(/_/g, '-')
12
+ }
13
+
14
+ /** Matches any valid `@kong/design-tokens` JS export name (e.g. `KUI_COLOR_TEXT_INVERSE`). */
15
+ export const KUI_IDENTIFIER_PATTERN = /^KUI_[A-Z0-9_]+$/
16
+
17
+ /** The npm package name from which KUI tokens are imported. */
18
+ export const KUI_IMPORT_SOURCE = '@kong/design-tokens'
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['eslint-plugin/rules/**/__tests__/**/*.test.mjs'],
6
+ environment: 'node',
7
+ },
8
+ })
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly, this file was auto-generated.
3
- * Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ * Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  *
5
5
  * Kong Design Tokens
6
6
  * GitHub: https://github.com/Kong/design-tokens
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly, this file was auto-generated.
3
- * Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ * Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  *
5
5
  * Kong Design Tokens
6
6
  * GitHub: https://github.com/Kong/design-tokens
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly, this file was auto-generated.
3
- * Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ * Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  *
5
5
  * Kong Design Tokens
6
6
  * GitHub: https://github.com/Kong/design-tokens
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly, this file was auto-generated.
3
- * Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ * Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  *
5
5
  * Kong Design Tokens
6
6
  * GitHub: https://github.com/Kong/design-tokens
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly, this file was auto-generated.
3
- * Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ * Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  *
5
5
  * Kong Design Tokens
6
6
  * GitHub: https://github.com/Kong/design-tokens
@@ -1,6 +1,6 @@
1
1
 
2
2
  // Do not edit directly, this file was auto-generated.
3
- // Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ // Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  //
5
5
  // Kong Design Tokens
6
6
  // GitHub: https://github.com/Kong/design-tokens
@@ -1,7 +1,7 @@
1
1
 
2
2
  /**
3
3
  * Do not edit directly, this file was auto-generated.
4
- * Generated on Thu, 14 May 2026 19:48:48 GMT
4
+ * Generated on Fri, 15 May 2026 18:00:44 GMT
5
5
  *
6
6
  * Kong Design Tokens
7
7
  * GitHub: https://github.com/Kong/design-tokens
@@ -1,6 +1,6 @@
1
1
 
2
2
  // Do not edit directly, this file was auto-generated.
3
- // Generated on Thu, 14 May 2026 19:48:48 GMT
3
+ // Generated on Fri, 15 May 2026 18:00:44 GMT
4
4
  //
5
5
  // Kong Design Tokens
6
6
  // GitHub: https://github.com/Kong/design-tokens
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@kong/design-tokens",
3
- "version": "1.22.1",
3
+ "version": "1.22.2-pr.656.bf3c7d3.0",
4
4
  "description": "Kong UI Design Tokens and style dictionary",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "build": "pnpm build:clean && style-dictionary build --config ./config.mjs && pnpm copy:tokens-doc && pnpm copy:stylelint-plugin",
7
+ "build": "pnpm build:clean && pnpm build:tokens && pnpm copy:tokens-doc && pnpm copy:stylelint-plugin && pnpm copy:eslint-plugin",
8
+ "build:tokens": "style-dictionary build --config ./config.mjs",
8
9
  "build:clean": "rimraf ./dist",
9
10
  "copy:tokens-doc": "shx cp -f './dist/tokens/README.md' './TOKENS.md'",
10
11
  "copy:stylelint-plugin": "shx cp -R './stylelint-plugin' './dist/stylelint-plugin/'",
12
+ "copy:eslint-plugin": "shx cp -R './eslint-plugin' './dist/eslint-plugin/'",
13
+ "test:eslint-plugin": "vitest run --config eslint-plugin/vitest.config.mjs",
11
14
  "lint": "eslint",
12
15
  "lint:fix": "eslint --fix",
13
- "typecheck": "vue-tsc --project ./sandbox/tsconfig.json --noEmit",
16
+ "typecheck": "pnpm typecheck:sandbox && pnpm typecheck:eslint-plugin",
17
+ "typecheck:sandbox": "vue-tsc --project ./sandbox/tsconfig.json --noEmit",
18
+ "typecheck:eslint-plugin": "tsc -p eslint-plugin/tsconfig.json --noEmit",
14
19
  "sandbox": "pnpm build && run-p sandbox:open sandbox:watch",
15
20
  "sandbox:open": "vite sandbox -c ./sandbox/vite.config.ts",
16
21
  "sandbox:watch": "chokidar \"tokens/**/*.json\" -c \"pnpm build\"",
@@ -44,8 +49,18 @@
44
49
  },
45
50
  "./tokens/*": "./dist/tokens/*",
46
51
  "./stylelint-plugin": "./dist/stylelint-plugin/index.mjs",
52
+ "./eslint-plugin": "./dist/eslint-plugin/index.mjs",
47
53
  "./package.json": "./package.json"
48
54
  },
55
+ "peerDependencies": {
56
+ "eslint": "^9.0.0",
57
+ "vue-eslint-parser": ">=10.4.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "vue-eslint-parser": {
61
+ "optional": true
62
+ }
63
+ },
49
64
  "repository": {
50
65
  "type": "git",
51
66
  "url": "https://github.com/Kong/design-tokens.git"
@@ -68,7 +83,8 @@
68
83
  "chokidar-cli": "^3.0.0",
69
84
  "commitizen": "^4.3.1",
70
85
  "cz-conventional-changelog": "^3.3.0",
71
- "eslint": "^9.39.3",
86
+ "eslint": "^9.39.4",
87
+ "eslint-plugin-vue": "^10.9.0",
72
88
  "npm-run-all2": "^8.0.4",
73
89
  "rimraf": "^6.1.3",
74
90
  "sass": "^1.97.3",
@@ -79,10 +95,15 @@
79
95
  "vite": "^8.0.0",
80
96
  "vite-plugin-restart": "^2.0.0",
81
97
  "vite-plugin-vue-devtools": "^8.0.7",
98
+ "vitest": "^4.1.5",
82
99
  "vue": "^3.5.29",
100
+ "vue-eslint-parser": "^10.4.0",
83
101
  "vue-router": "^5.0.3",
84
102
  "vue-tsc": "^3.2.7"
85
103
  },
104
+ "overrides": {
105
+ "semver": "^7.8.0"
106
+ },
86
107
  "release": {
87
108
  "branches": [
88
109
  "+([0-9])?(.{+([0-9]),x}).x",
@@ -145,6 +166,9 @@
145
166
  "@evilmartians/lefthook",
146
167
  "esbuild",
147
168
  "style-dictionary"
148
- ]
169
+ ],
170
+ "overrides": {
171
+ "semver": "^7.6.0"
172
+ }
149
173
  }
150
174
  }