@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.
- package/dist/eslint-plugin/README.md +136 -0
- package/dist/eslint-plugin/index.mjs +36 -0
- package/dist/eslint-plugin/rules/token-constant-requires-css-var/__tests__/index.test.mjs +558 -0
- package/dist/eslint-plugin/rules/token-constant-requires-css-var/index.mjs +328 -0
- package/dist/eslint-plugin/tsconfig.json +21 -0
- package/dist/eslint-plugin/utilities/index.mjs +18 -0
- package/dist/eslint-plugin/vitest.config.mjs +8 -0
- package/dist/tokens/css/custom-properties.css +1 -1
- package/dist/tokens/js/cjs/index.d.ts +1 -1
- package/dist/tokens/js/cjs/index.js +1 -1
- package/dist/tokens/js/index.d.ts +1 -1
- package/dist/tokens/js/index.mjs +1 -1
- package/dist/tokens/less/variables.less +1 -1
- package/dist/tokens/scss/_map.scss +1 -1
- package/dist/tokens/scss/_variables.scss +1 -1
- package/package.json +29 -5
|
@@ -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'
|
package/dist/tokens/js/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kong/design-tokens",
|
|
3
|
-
"version": "1.22.
|
|
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 &&
|
|
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": "
|
|
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.
|
|
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
|
}
|