@kong/eslint-plugin-design-tokens 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +174 -0
- package/index.mjs +35 -0
- package/package.json +47 -0
- package/rules/token-constant-requires-css-var/__tests__/index.test.mjs +1017 -0
- package/rules/token-constant-requires-css-var/index.mjs +500 -0
- package/utilities/index.mjs +18 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_IMPORT_SOURCES,
|
|
3
|
+
KUI_IDENTIFIER_PATTERN,
|
|
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
|
+
* Token name prefix that is excluded from this rule.
|
|
15
|
+
* `KUI_BREAKPOINT_*` constants represent viewport breakpoints (pixel widths).
|
|
16
|
+
* CSS custom properties are not valid inside a `@media` query, so shouldn't be enforced with a var() fallback.
|
|
17
|
+
*/
|
|
18
|
+
const KUI_EXCLUDED_PREFIX = 'KUI_BREAKPOINT_'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* TypeScript "transparent" wrapper node types — present when @typescript-eslint/parser
|
|
22
|
+
* parses the SFC `<script>` block. They wrap an inner expression without changing its
|
|
23
|
+
* runtime value, so the rule unwraps them to reach the underlying Identifier.
|
|
24
|
+
* These types are not in the estree Node union, so callers compare against a widened string.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} nodeType
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function isTsWrapperType(nodeType) {
|
|
30
|
+
return nodeType === 'TSAsExpression' // `x as T`
|
|
31
|
+
|| nodeType === 'TSSatisfiesExpression' // `x satisfies T`
|
|
32
|
+
|| nodeType === 'TSNonNullExpression' // `x!`
|
|
33
|
+
|| nodeType === 'TSTypeAssertion' // `<T>x`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns `true` when the TemplateLiteral expression slot at `exprIndex` is
|
|
38
|
+
* already in the form `` `var(<expectedCssVar>, ${IDENTIFIER})` `` for the
|
|
39
|
+
* specific token, so we can skip it on subsequent lint passes (idempotency).
|
|
40
|
+
*
|
|
41
|
+
* Requires the exact CSS var name so that a mismatched wrapper such as
|
|
42
|
+
* `` `var(--kui-color-text-primary, ${KUI_COLOR_TEXT_INVERSE})` `` is NOT
|
|
43
|
+
* treated as already-wrapped and continues to be reported.
|
|
44
|
+
*
|
|
45
|
+
* @param {import('estree').TemplateLiteral} templateNode
|
|
46
|
+
* @param {number} exprIndex - Index of the expression slot to inspect
|
|
47
|
+
* @param {string} expectedCssVar - Full CSS custom property name, e.g. `--kui-color-text-inverse`
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function isAlreadyWrappedSlot(templateNode, exprIndex, expectedCssVar) {
|
|
51
|
+
const priorText = templateNode.quasis[exprIndex]?.value?.cooked ?? ''
|
|
52
|
+
const nextText = templateNode.quasis[exprIndex + 1]?.value?.cooked ?? ''
|
|
53
|
+
const escaped = expectedCssVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
54
|
+
/**
|
|
55
|
+
* Allow optional whitespace after `var(`, around the custom property name, and
|
|
56
|
+
* before the comma — CSS is whitespace-tolerant in these positions. trimStart()
|
|
57
|
+
* before startsWith(')') keeps permissiveness for trailing content like
|
|
58
|
+
* `) !important` while also accepting a space before the closing paren.
|
|
59
|
+
*/
|
|
60
|
+
return new RegExp(`var\\(\\s*${escaped}\\s*,\\s*$`).test(priorText) && nextText.trimStart().startsWith(')')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* If `node` is directly an Identifier — or an Identifier wrapped only in
|
|
65
|
+
* TypeScript transparent wrappers (see `isTsWrapperType`) — returns that
|
|
66
|
+
* Identifier. Otherwise returns `null`.
|
|
67
|
+
*
|
|
68
|
+
* Used to detect the case where a TemplateLiteral slot's expression is a bare
|
|
69
|
+
* token reference so the caller can supply parent-template context for the
|
|
70
|
+
* token-specific idempotency check.
|
|
71
|
+
*
|
|
72
|
+
* @param {import('estree').Node} node
|
|
73
|
+
* @returns {import('estree').Identifier | null}
|
|
74
|
+
*/
|
|
75
|
+
function asDirectIdentifier(node) {
|
|
76
|
+
if (isTsWrapperType(/** @type {string} */ (node.type))) {
|
|
77
|
+
return asDirectIdentifier(/** @type {{ expression: import('estree').Node }} */ (node).expression)
|
|
78
|
+
}
|
|
79
|
+
return node.type === 'Identifier' ? /** @type {import('estree').Identifier} */ (node) : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Recursively walks a v-bind expression tree, calling `onIdentifier` for each
|
|
84
|
+
* `Identifier` node that should be inspected for KUI token usage.
|
|
85
|
+
*
|
|
86
|
+
* The `ctx` parameter propagates through the tree and is downgraded from
|
|
87
|
+
* `AUTOFIX` to `REPORT_ONLY` when entering contexts where replacing an
|
|
88
|
+
* Identifier with a backtick template literal would be unsafe (e.g. inside an
|
|
89
|
+
* existing TemplateLiteral, a BinaryExpression, or a CallExpression argument).
|
|
90
|
+
*
|
|
91
|
+
* @param {import('estree').Node | null | undefined} node - Current AST node
|
|
92
|
+
* @param {string} ctx - Either `AUTOFIX` or `REPORT_ONLY`
|
|
93
|
+
* @param {(id: import('estree').Identifier, ctx: string, slot?: { parentTemplate?: import('estree').TemplateLiteral, slotIndex?: number, parentShorthandProp?: import('estree').Property }) => void} onIdentifier
|
|
94
|
+
*/
|
|
95
|
+
function walkExpression(node, ctx, onIdentifier) {
|
|
96
|
+
if (!node) return
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unwrap TypeScript transparent wrappers (e.g. `KUI_X as string`, `KUI_X!`),
|
|
100
|
+
* preserving the current autofix/report context.
|
|
101
|
+
*/
|
|
102
|
+
if (isTsWrapperType(/** @type {string} */ (node.type))) {
|
|
103
|
+
walkExpression(/** @type {{ expression: import('estree').Node }} */ (node).expression, ctx, onIdentifier)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
switch (node.type) {
|
|
108
|
+
case 'Identifier':
|
|
109
|
+
onIdentifier(/** @type {import('estree').Identifier} */ (node), ctx)
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
case 'ConditionalExpression': {
|
|
113
|
+
const n = /** @type {import('estree').ConditionalExpression} */ (node)
|
|
114
|
+
/** Walk value branches only; the boolean test is not a style value. */
|
|
115
|
+
walkExpression(n.consequent, ctx, onIdentifier)
|
|
116
|
+
walkExpression(n.alternate, ctx, onIdentifier)
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'LogicalExpression': {
|
|
121
|
+
const n = /** @type {import('estree').LogicalExpression} */ (node)
|
|
122
|
+
walkExpression(n.left, ctx, onIdentifier)
|
|
123
|
+
walkExpression(n.right, ctx, onIdentifier)
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case 'ObjectExpression': {
|
|
128
|
+
const n = /** @type {import('estree').ObjectExpression} */ (node)
|
|
129
|
+
for (const prop of n.properties) {
|
|
130
|
+
if (prop.type !== 'Property') continue
|
|
131
|
+
/** SpreadElement is not walked — shape is unknown at static analysis time. */
|
|
132
|
+
const p = /** @type {import('estree').Property} */ (prop)
|
|
133
|
+
if (p.shorthand && p.value.type === 'Identifier') {
|
|
134
|
+
/**
|
|
135
|
+
* Shorthand { KUI_X }: replacing just the identifier drops the key,
|
|
136
|
+
* yielding `{ \`var(...)\` }` — invalid JS. Pass the Property node so the
|
|
137
|
+
* fixer can produce the expanded form: `{ KUI_X: \`var(...)\` }`.
|
|
138
|
+
*/
|
|
139
|
+
onIdentifier(/** @type {import('estree').Identifier} */ (p.value), ctx, { parentShorthandProp: p })
|
|
140
|
+
} else {
|
|
141
|
+
walkExpression(p.value, ctx, onIdentifier)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'ArrayExpression': {
|
|
148
|
+
const n = /** @type {import('estree').ArrayExpression} */ (node)
|
|
149
|
+
for (const el of n.elements) {
|
|
150
|
+
if (el) walkExpression(el, ctx, onIdentifier)
|
|
151
|
+
}
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'TemplateLiteral': {
|
|
156
|
+
const n = /** @type {import('estree').TemplateLiteral} */ (node)
|
|
157
|
+
/**
|
|
158
|
+
* Replacing an Identifier inside `${}` with another backtick string would
|
|
159
|
+
* nest backticks (invalid JS), so all slots are REPORT_ONLY. When the slot
|
|
160
|
+
* expression is a direct Identifier (possibly TS-wrapped), pass the template
|
|
161
|
+
* + slot index so `handleIdentifier` can run a token-specific idempotency
|
|
162
|
+
* check (e.g. `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`).
|
|
163
|
+
* Nested expressions (ternary, call, etc.) never get slot context — they are
|
|
164
|
+
* always reported without the idempotency escape.
|
|
165
|
+
*/
|
|
166
|
+
n.expressions.forEach((expr, i) => {
|
|
167
|
+
const directId = asDirectIdentifier(expr)
|
|
168
|
+
if (directId) {
|
|
169
|
+
onIdentifier(directId, REPORT_ONLY, { parentTemplate: n, slotIndex: i })
|
|
170
|
+
} else {
|
|
171
|
+
walkExpression(expr, REPORT_ONLY, onIdentifier)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'CallExpression': {
|
|
178
|
+
const n = /** @type {import('estree').CallExpression} */ (node)
|
|
179
|
+
/**
|
|
180
|
+
* Arguments are REPORT_ONLY: the function may expect a raw color value
|
|
181
|
+
* (e.g. darken(), rgba()) and wrapping in var() would break it.
|
|
182
|
+
*/
|
|
183
|
+
for (const arg of n.arguments) {
|
|
184
|
+
walkExpression(arg, REPORT_ONLY, onIdentifier)
|
|
185
|
+
}
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case 'BinaryExpression': {
|
|
190
|
+
const n = /** @type {import('estree').BinaryExpression} */ (node)
|
|
191
|
+
/** String concat or arithmetic — wrapping changes the resulting value type. */
|
|
192
|
+
walkExpression(n.left, REPORT_ONLY, onIdentifier)
|
|
193
|
+
walkExpression(n.right, REPORT_ONLY, onIdentifier)
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Standard optional-chaining wrapper (e.g. `a?.b`). */
|
|
198
|
+
case 'ChainExpression': {
|
|
199
|
+
const n = /** @type {import('estree').ChainExpression} */ (node)
|
|
200
|
+
walkExpression(n.expression, ctx, onIdentifier)
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'AssignmentExpression': {
|
|
205
|
+
const n = /** @type {import('estree').AssignmentExpression} */ (node)
|
|
206
|
+
walkExpression(n.right, REPORT_ONLY, onIdentifier)
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case 'MemberExpression': {
|
|
211
|
+
const n = /** @type {import('estree').MemberExpression} */ (node)
|
|
212
|
+
/** Walk the object side only; property names are not value references. */
|
|
213
|
+
walkExpression(n.object, REPORT_ONLY, onIdentifier)
|
|
214
|
+
break
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'SequenceExpression': {
|
|
218
|
+
const n = /** @type {import('estree').SequenceExpression} */ (node)
|
|
219
|
+
const last = n.expressions.length - 1
|
|
220
|
+
n.expressions.forEach((expr, i) => {
|
|
221
|
+
walkExpression(expr, i === last ? ctx : REPORT_ONLY, onIdentifier)
|
|
222
|
+
})
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
default:
|
|
227
|
+
/** Unknown or unhandled node type — stop recursing. */
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
233
|
+
const rule = {
|
|
234
|
+
meta: {
|
|
235
|
+
type: 'problem',
|
|
236
|
+
docs: {
|
|
237
|
+
description:
|
|
238
|
+
'Enforce CSS custom property var() fallback for KUI design tokens in Vue template v-bind expressions',
|
|
239
|
+
url: 'https://github.com/Kong/design-tokens/blob/main/eslint-plugin/README.md',
|
|
240
|
+
},
|
|
241
|
+
fixable: 'code',
|
|
242
|
+
hasSuggestions: false,
|
|
243
|
+
schema: [{
|
|
244
|
+
type: 'object',
|
|
245
|
+
properties: {
|
|
246
|
+
importSources: {
|
|
247
|
+
type: 'array',
|
|
248
|
+
items: { type: 'string' },
|
|
249
|
+
minItems: 1,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
}],
|
|
254
|
+
messages: {
|
|
255
|
+
wrapInVar:
|
|
256
|
+
"Kong design token '{{local}}' must be wrapped in a CSS custom property fallback. " +
|
|
257
|
+
'Use `var(--{{cssVar}}, ${{{local}}})` so DOM-level theme overrides (e.g., light/dark mode) take effect.',
|
|
258
|
+
wrapInVarNoFix:
|
|
259
|
+
"Kong design token '{{local}}' must be wrapped in a CSS custom property fallback, " +
|
|
260
|
+
'but cannot be auto-fixed in this expression context. ' +
|
|
261
|
+
'Manually change to: `var(--{{cssVar}}, ${{{local}}})` at the binding site ' +
|
|
262
|
+
'so DOM-level theme overrides (e.g., light/dark mode) take effect.',
|
|
263
|
+
wrapInVarScriptSetup:
|
|
264
|
+
"Kong design token '{{imported}}' is stored in variable '{{local}}' which flows into this style binding. " +
|
|
265
|
+
'Wrap the token at the template binding site: `var(--{{cssVar}}, ${{{local}}})`, or use the import directly ' +
|
|
266
|
+
'so DOM-level theme overrides (e.g., light/dark mode) take effect.',
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
create(context) {
|
|
271
|
+
const importSources = context.options[0]?.importSources ?? DEFAULT_IMPORT_SOURCES
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Maps each tracked local name to the canonical imported name.
|
|
275
|
+
* e.g. `import { KUI_COLOR_TEXT_INVERSE as myColor }` → `{ 'myColor' → 'KUI_COLOR_TEXT_INVERSE' }`
|
|
276
|
+
* @type {Map<string, string>}
|
|
277
|
+
*/
|
|
278
|
+
const trackedImports = new Map()
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Maps script-setup variable names to the import local name they were
|
|
282
|
+
* initialised from, for one-hop script detection.
|
|
283
|
+
* e.g. `const c = KUI_X` (where `KUI_X` is in trackedImports) → `{ 'c' → 'KUI_X' }`
|
|
284
|
+
* @type {Map<string, string>}
|
|
285
|
+
*/
|
|
286
|
+
const trackedScriptVars = new Map()
|
|
287
|
+
|
|
288
|
+
/** @type {{ defineTemplateBodyVisitor?: Function } | undefined} */
|
|
289
|
+
const parserServices =
|
|
290
|
+
context.sourceCode?.parserServices ?? /** @type {any} */ (context).parserServices
|
|
291
|
+
|
|
292
|
+
if (!parserServices?.defineTemplateBodyVisitor) {
|
|
293
|
+
/**
|
|
294
|
+
* vue-eslint-parser is not configured; no-op for plain JS/TS files.
|
|
295
|
+
* .vue files linted without vue-eslint-parser fail to parse before rules
|
|
296
|
+
* run, so there is nothing useful to report here.
|
|
297
|
+
*/
|
|
298
|
+
return {}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Source range of the <script setup> block, used to restrict one-hop variable
|
|
303
|
+
* tracking to declarations actually reachable from the template. Three states:
|
|
304
|
+
* [number, number] — <script setup> found; track only declarations inside it
|
|
305
|
+
* null — SFC has no <script setup> (Options API); track nothing
|
|
306
|
+
* undefined — getDocumentFragment unavailable; fall back to scope-only check
|
|
307
|
+
*/
|
|
308
|
+
const df = /** @type {any} */ (parserServices).getDocumentFragment?.()
|
|
309
|
+
const scriptSetupEl = /** @type {any[] | undefined} */ (df?.children)?.find(
|
|
310
|
+
/**
|
|
311
|
+
* Match on n.name (normalized lowercase) not n.rawName (source casing) so
|
|
312
|
+
* <Script setup> / <SCRIPT SETUP> are still recognized.
|
|
313
|
+
*/
|
|
314
|
+
(/** @type {any} */ n) => n.type === 'VElement'
|
|
315
|
+
&& n.name === 'script'
|
|
316
|
+
&& n.startTag?.attributes?.some((/** @type {any} */ a) => a.key?.name === 'setup'),
|
|
317
|
+
)
|
|
318
|
+
/** @type {readonly [number, number] | null | undefined} */
|
|
319
|
+
const scriptSetupRange = !df ? undefined : scriptSetupEl ? /** @type {any} */ (scriptSetupEl).range : null
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Whether `node` is a module-scope declarator reachable from the template —
|
|
323
|
+
* the only place a one-hop `const c = KUI_X` alias can flow into a style binding.
|
|
324
|
+
* Excludes function-scoped locals and (when a <script setup> range is known)
|
|
325
|
+
* declarations outside it, e.g. module-scope consts in an Options API `<script>`.
|
|
326
|
+
*
|
|
327
|
+
* @param {import('estree').VariableDeclarator} node
|
|
328
|
+
* @returns {boolean}
|
|
329
|
+
*/
|
|
330
|
+
function isTemplateReachableDeclarator(node) {
|
|
331
|
+
if (context.sourceCode.getScope(node).type !== 'module') return false
|
|
332
|
+
/** No <script setup> block (Options API): module-scope vars are not template-exposed. */
|
|
333
|
+
if (scriptSetupRange === null) return false
|
|
334
|
+
/** getDocumentFragment unavailable: the module-scope check above is sufficient. */
|
|
335
|
+
if (scriptSetupRange === undefined) return true
|
|
336
|
+
const [start, end] = /** @type {import('estree').VariableDeclarator & { range: [number, number] }} */ (node).range
|
|
337
|
+
return start >= scriptSetupRange[0] && end <= scriptSetupRange[1]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Reports a KUI token Identifier found inside a v-bind expression.
|
|
342
|
+
*
|
|
343
|
+
* The identifier resolves either to a directly-imported token (autofixable)
|
|
344
|
+
* or a one-hop `<script setup>` alias of one (report-only). In both cases the
|
|
345
|
+
* canonical imported name yields the CSS var, so the idempotency check runs once.
|
|
346
|
+
*
|
|
347
|
+
* @param {import('estree').Identifier} idNode - The token identifier node
|
|
348
|
+
* @param {string} ctx - Either `AUTOFIX` or `REPORT_ONLY`
|
|
349
|
+
* @param {object} [slot] - Positional context, when present
|
|
350
|
+
* @param {import('estree').TemplateLiteral} [slot.parentTemplate] - Enclosing template literal, if the identifier is its direct expression
|
|
351
|
+
* @param {number} [slot.slotIndex] - Expression slot index within `parentTemplate`
|
|
352
|
+
* @param {import('estree').Property} [slot.parentShorthandProp] - Enclosing shorthand Property node, if any
|
|
353
|
+
*/
|
|
354
|
+
function handleIdentifier(idNode, ctx, { parentTemplate, slotIndex, parentShorthandProp } = {}) {
|
|
355
|
+
const localName = idNode.name
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Resolve the canonical imported token name: either a direct import (or its
|
|
359
|
+
* local alias), or a one-hop `<script setup>` variable initialised from one.
|
|
360
|
+
*/
|
|
361
|
+
const directImport = trackedImports.get(localName)
|
|
362
|
+
const importedName = directImport ?? trackedImports.get(trackedScriptVars.get(localName) ?? '')
|
|
363
|
+
if (!importedName) return
|
|
364
|
+
|
|
365
|
+
const cssVar = kuiIdentifierToCssVar(importedName)
|
|
366
|
+
/** Strip the leading `--` for display in the message. */
|
|
367
|
+
const cssVarNoPrefix = cssVar.slice(2)
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Idempotency: when the identifier is the direct expression of a TemplateLiteral
|
|
371
|
+
* slot, skip it only if the slot is already wrapped with the CORRECT CSS var for
|
|
372
|
+
* THIS token. A mismatched wrapper like `var(--kui-color-text-primary, ${KUI_COLOR_TEXT_INVERSE})`
|
|
373
|
+
* does NOT satisfy idempotency and falls through to be reported.
|
|
374
|
+
*/
|
|
375
|
+
if (parentTemplate !== undefined && slotIndex !== undefined
|
|
376
|
+
&& isAlreadyWrappedSlot(parentTemplate, slotIndex, cssVar)) {
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* One-hop script-setup alias: report at the binding site, never autofixed
|
|
382
|
+
* (fixing the declaration or inlining the alias would change semantics).
|
|
383
|
+
*/
|
|
384
|
+
if (!directImport) {
|
|
385
|
+
context.report({
|
|
386
|
+
node: idNode,
|
|
387
|
+
messageId: 'wrapInVarScriptSetup',
|
|
388
|
+
data: { imported: importedName, local: localName, cssVar: cssVarNoPrefix },
|
|
389
|
+
})
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Direct import: autofixable when the expression position is safe. */
|
|
394
|
+
if (ctx === AUTOFIX) {
|
|
395
|
+
context.report({
|
|
396
|
+
node: idNode,
|
|
397
|
+
messageId: 'wrapInVar',
|
|
398
|
+
data: { local: localName, cssVar: cssVarNoPrefix },
|
|
399
|
+
fix(fixer) {
|
|
400
|
+
const wrapped = `\`var(${cssVar}, \${${localName}})\``
|
|
401
|
+
/** Shorthand { KUI_X } must keep its key: expand to { KUI_X: `var(...)` }. */
|
|
402
|
+
if (parentShorthandProp) {
|
|
403
|
+
return fixer.replaceText(parentShorthandProp, `${localName}: ${wrapped}`)
|
|
404
|
+
}
|
|
405
|
+
return fixer.replaceText(idNode, wrapped)
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
} else {
|
|
409
|
+
context.report({
|
|
410
|
+
node: idNode,
|
|
411
|
+
messageId: 'wrapInVarNoFix',
|
|
412
|
+
data: { local: localName, cssVar: cssVarNoPrefix },
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const templateVisitor = {
|
|
418
|
+
/** Fires for every `:prop="..."` and `v-bind="..."` attribute in the template. */
|
|
419
|
+
'VAttribute[directive=true][key.name.name="bind"]'(
|
|
420
|
+
/** @type {any} */ node,
|
|
421
|
+
) {
|
|
422
|
+
const valueContainer = node.value
|
|
423
|
+
if (!valueContainer?.expression) return
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Identifier nodes that resolve to a template-local variable — a `v-for`
|
|
427
|
+
* item, scoped-slot prop, etc. (vue-eslint-parser sets `ref.variable` for
|
|
428
|
+
* these). Such a name shadows any same-named token import, so it must be
|
|
429
|
+
* skipped to avoid a false positive on e.g. `v-for="KUI_X in list"`.
|
|
430
|
+
*/
|
|
431
|
+
const templateScoped = new Set(
|
|
432
|
+
/** @type {any[]} */ (valueContainer.references ?? [])
|
|
433
|
+
.filter((/** @type {any} */ ref) => ref.variable != null)
|
|
434
|
+
.map((/** @type {any} */ ref) => ref.id),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
walkExpression(valueContainer.expression, AUTOFIX, (idNode, ctx, slot) => {
|
|
438
|
+
if (templateScoped.has(idNode)) return
|
|
439
|
+
handleIdentifier(idNode, ctx, slot)
|
|
440
|
+
})
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const scriptVisitor = {
|
|
445
|
+
/** Collects KUI_ named imports from configured import sources. */
|
|
446
|
+
ImportDeclaration(/** @type {import('estree').ImportDeclaration} */ node) {
|
|
447
|
+
if (!importSources.includes(/** @type {string} */ (node.source.value))) return
|
|
448
|
+
/** Skip `import type { ... }` — type-only imports have no runtime value. */
|
|
449
|
+
if (/** @type {any} */ (node).importKind === 'type') return
|
|
450
|
+
|
|
451
|
+
for (const specifier of node.specifiers) {
|
|
452
|
+
if (specifier.type !== 'ImportSpecifier') continue
|
|
453
|
+
/** Skip per-specifier `import { type KUI_X }` (TypeScript syntax). */
|
|
454
|
+
if (/** @type {any} */ (specifier).importKind === 'type') continue
|
|
455
|
+
|
|
456
|
+
const imported = /** @type {import('estree').ImportSpecifier} */ (specifier).imported
|
|
457
|
+
const importedName = /** @type {string} */ (
|
|
458
|
+
'name' in imported ? imported.name : /** @type {any} */ (imported).value
|
|
459
|
+
)
|
|
460
|
+
const localName = specifier.local.name
|
|
461
|
+
|
|
462
|
+
if (!KUI_IDENTIFIER_PATTERN.test(importedName)) continue
|
|
463
|
+
if (importedName.startsWith(KUI_EXCLUDED_PREFIX)) continue
|
|
464
|
+
|
|
465
|
+
trackedImports.set(localName, importedName)
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Detects `const c = KUI_X` in `<script setup>` (one-hop alias detection).
|
|
471
|
+
* Only simple `Identifier = Identifier` initialisers that are reachable from
|
|
472
|
+
* the template are tracked (see `isTemplateReachableDeclarator`).
|
|
473
|
+
*/
|
|
474
|
+
VariableDeclarator(/** @type {import('estree').VariableDeclarator} */ node) {
|
|
475
|
+
if (node.id?.type !== 'Identifier') return
|
|
476
|
+
if (!node.init) return
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Unwrap TS transparent wrappers on the initializer so aliases such as
|
|
480
|
+
* `const c = KUI_X as string` or `const c = KUI_X!` are tracked, matching
|
|
481
|
+
* how the template side unwraps them. Non-Identifier initializers (e.g.
|
|
482
|
+
* `ref(KUI_X)`, object literals) resolve to null and are correctly ignored.
|
|
483
|
+
*/
|
|
484
|
+
const initId = asDirectIdentifier(node.init)
|
|
485
|
+
if (!initId) return
|
|
486
|
+
if (!isTemplateReachableDeclarator(node)) return
|
|
487
|
+
if (!trackedImports.has(initId.name)) return
|
|
488
|
+
|
|
489
|
+
trackedScriptVars.set(
|
|
490
|
+
/** @type {import('estree').Identifier} */ (node.id).name,
|
|
491
|
+
initId.name,
|
|
492
|
+
)
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return parserServices.defineTemplateBodyVisitor(templateVisitor, scriptVisitor)
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export default rule
|
|
@@ -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
|
+
/** Default package names recognized as KUI token sources. */
|
|
18
|
+
export const DEFAULT_IMPORT_SOURCES = ['@kong/design-tokens', '@kong/portal-design-tokens']
|