@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.
@@ -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']