@primer/mcp 0.2.0 → 0.3.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.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAA;AAUjE,QAAA,MAAM,MAAM,WAGV,CAAA;AAutBF,OAAO,EAAC,MAAM,EAAC,CAAA"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,SAAS,EAAC,MAAM,yCAAyC,CAAA;AAuBjE,QAAA,MAAM,MAAM,WAGV,CAAA;AAu5BF,OAAO,EAAC,MAAM,EAAC,CAAA"}
package/dist/stdio.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
- import { s as server } from './server-CjO5UCV7.js';
2
+ import { s as server } from './server-Cwz0naYT.js';
3
3
  import '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import 'cheerio';
5
5
  import 'zod';
6
6
  import 'turndown';
7
7
  import '@primer/react/generated/components.json' with { type: 'json' };
8
8
  import '@primer/octicons/build/data.json' with { type: 'json' };
9
+ import 'node:fs';
10
+ import 'node:module';
9
11
  import '@primer/primitives/dist/docs/base/motion/motion.json' with { type: 'json' };
10
12
  import '@primer/primitives/dist/docs/base/size/size.json' with { type: 'json' };
11
13
  import '@primer/primitives/dist/docs/base/typography/typography.json' with { type: 'json' };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@primer/mcp",
3
3
  "description": "An MCP server that connects AI tools to the Primer Design System",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mcp": "./bin/mcp.js"
@@ -54,7 +54,7 @@
54
54
  "@rollup/plugin-node-resolve": "^16.0.3",
55
55
  "@types/turndown": "^5.0.5",
56
56
  "rimraf": "^6.0.1",
57
- "rollup": "^4.56.0",
57
+ "rollup": "^4.59.0",
58
58
  "rollup-plugin-typescript2": "^0.36.0",
59
59
  "typescript": "^5.9.2"
60
60
  }
package/src/primitives.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import {readFileSync} from 'node:fs'
2
+ import {createRequire} from 'node:module'
1
3
  import baseMotion from '@primer/primitives/dist/docs/base/motion/motion.json' with {type: 'json'}
2
4
  import baseSize from '@primer/primitives/dist/docs/base/size/size.json' with {type: 'json'}
3
5
  import baseTypography from '@primer/primitives/dist/docs/base/typography/typography.json' with {type: 'json'}
@@ -8,6 +10,16 @@ import functionalSize from '@primer/primitives/dist/docs/functional/size/size.js
8
10
  import light from '@primer/primitives/dist/docs/functional/themes/light.json' with {type: 'json'}
9
11
  import functionalTypography from '@primer/primitives/dist/docs/functional/typography/typography.json' with {type: 'json'}
10
12
 
13
+ // radius.json may not exist in all versions of @primer/primitives
14
+ let functionalSizeRadius: Record<string, {name: string; type: string; value: string}> = {}
15
+ try {
16
+ const require = createRequire(import.meta.url)
17
+ const radiusPath = require.resolve('@primer/primitives/dist/docs/functional/size/radius.json')
18
+ functionalSizeRadius = JSON.parse(readFileSync(radiusPath, 'utf-8'))
19
+ } catch {
20
+ // radius.json not available in this version of @primer/primitives
21
+ }
22
+
11
23
  const categories = {
12
24
  base: {
13
25
  motion: Object.values(baseMotion).map(token => {
@@ -40,6 +52,13 @@ const categories = {
40
52
  value: token.value,
41
53
  }
42
54
  }),
55
+ radius: Object.values(functionalSizeRadius).map(token => {
56
+ return {
57
+ name: token.name,
58
+ type: token.type,
59
+ value: token.value,
60
+ }
61
+ }),
43
62
  sizeCoarse: Object.values(functionalSizeCoarse).map(token => {
44
63
  return {
45
64
  name: token.name,
@@ -85,6 +104,7 @@ const tokens = [
85
104
  ...categories.base.size,
86
105
  ...categories.base.typography,
87
106
  ...categories.functional.border,
107
+ ...categories.functional.radius,
88
108
  ...categories.functional.sizeCoarse,
89
109
  ...categories.functional.sizeFine,
90
110
  ...categories.functional.size,
@@ -100,4 +120,609 @@ function serialize(value: typeof tokens): string {
100
120
  .join('\n')
101
121
  }
102
122
 
103
- export {categories, tokens, serialize}
123
+ // Semantic group prefixes that apply to any element
124
+ const SEMANTIC_PREFIXES = ['bgColor', 'fgColor', 'border', 'borderColor', 'shadow', 'focus', 'color']
125
+
126
+ type TokenGroup = {
127
+ name: string
128
+ count: number
129
+ subGroups?: string[]
130
+ }
131
+
132
+ type TokenGroups = {
133
+ semantic: TokenGroup[]
134
+ component: TokenGroup[]
135
+ }
136
+
137
+ function listTokenGroups(): TokenGroups {
138
+ // Use the full token set so non-theme groups (stack, text, borderRadius, etc.) are included
139
+ const allTokens = tokens
140
+
141
+ // Group tokens by their first segment
142
+ const groupMap = new Map<string, {count: number; subGroups: Set<string>}>()
143
+
144
+ for (const token of allTokens) {
145
+ const parts = token.name.split('-')
146
+ const prefix = parts[0]
147
+
148
+ if (!groupMap.has(prefix)) {
149
+ groupMap.set(prefix, {count: 0, subGroups: new Set()})
150
+ }
151
+
152
+ const group = groupMap.get(prefix)!
153
+ group.count++
154
+
155
+ // For component tokens, track sub-groups (e.g., button-bgColor -> bgColor)
156
+ if (!SEMANTIC_PREFIXES.includes(prefix) && parts.length > 1) {
157
+ const subGroup = parts[1]
158
+ if (SEMANTIC_PREFIXES.includes(subGroup)) {
159
+ group.subGroups.add(subGroup)
160
+ }
161
+ }
162
+ }
163
+
164
+ const semantic: TokenGroup[] = []
165
+ const component: TokenGroup[] = []
166
+
167
+ for (const [name, data] of groupMap.entries()) {
168
+ const group: TokenGroup = {
169
+ name,
170
+ count: data.count,
171
+ }
172
+
173
+ if (data.subGroups.size > 0) {
174
+ group.subGroups = Array.from(data.subGroups).sort()
175
+ }
176
+
177
+ if (SEMANTIC_PREFIXES.includes(name)) {
178
+ semantic.push(group)
179
+ } else {
180
+ component.push(group)
181
+ }
182
+ }
183
+
184
+ // Sort by count descending
185
+ semantic.sort((a, b) => b.count - a.count)
186
+ component.sort((a, b) => b.count - a.count)
187
+
188
+ return {semantic, component}
189
+ }
190
+
191
+ export {categories, tokens, serialize, listTokenGroups, type TokenGroups}
192
+
193
+ // Token with guidelines from markdown
194
+ type TokenWithGuidelines = {
195
+ name: string
196
+ value: string
197
+ useCase: string
198
+ rules: string
199
+ group: string
200
+ }
201
+
202
+ // Parse design tokens spec from new DESIGN_TOKENS_SPEC.md format
203
+ function parseDesignTokensSpec(markdown: string): TokenWithGuidelines[] {
204
+ const results: TokenWithGuidelines[] = []
205
+ const lines = markdown.split('\n')
206
+
207
+ let currentGroup = ''
208
+ let currentToken: Partial<TokenWithGuidelines> | null = null
209
+ let descriptionLines: string[] = []
210
+
211
+ for (const line of lines) {
212
+ // Match group headings (## heading)
213
+ const groupMatch = line.match(/^## (.+)$/)
214
+ if (groupMatch) {
215
+ // Save previous token if exists
216
+ if (currentToken?.name) {
217
+ results.push({
218
+ name: currentToken.name,
219
+ value: getTokenValue(currentToken.name),
220
+ useCase: currentToken.useCase || descriptionLines.join(' '),
221
+ rules: currentToken.rules || '',
222
+ group: currentToken.group || '',
223
+ })
224
+ descriptionLines = []
225
+ }
226
+ currentGroup = groupMatch[1].trim()
227
+ currentToken = null
228
+ continue
229
+ }
230
+
231
+ // Match token name (### tokenName)
232
+ const tokenMatch = line.match(/^### (.+)$/)
233
+ if (tokenMatch) {
234
+ // Save previous token if exists
235
+ if (currentToken?.name) {
236
+ results.push({
237
+ name: currentToken.name,
238
+ value: getTokenValue(currentToken.name),
239
+ useCase: currentToken.useCase || descriptionLines.join(' '),
240
+ rules: currentToken.rules || '',
241
+ group: currentToken.group || '',
242
+ })
243
+ }
244
+ descriptionLines = []
245
+ currentToken = {
246
+ name: tokenMatch[1].trim(),
247
+ group: currentGroup,
248
+ }
249
+ continue
250
+ }
251
+
252
+ // Match new format usage line (**U:**)
253
+ const newUsageMatch = line.match(/^\*\*U:\*\*\s*(.+)$/)
254
+ if (newUsageMatch && currentToken) {
255
+ currentToken.useCase = newUsageMatch[1].trim()
256
+ continue
257
+ }
258
+
259
+ // Match new format rules line (**R:**)
260
+ const newRulesMatch = line.match(/^\*\*R:\*\*\s*(.+)$/)
261
+ if (newRulesMatch && currentToken) {
262
+ currentToken.rules = newRulesMatch[1].trim()
263
+ continue
264
+ }
265
+
266
+ // Description line (line after token name, before U:/R:)
267
+ if (currentToken && !currentToken.useCase && !line.startsWith('**') && line.trim() && !line.startsWith('#')) {
268
+ descriptionLines.push(line.trim())
269
+ }
270
+ }
271
+
272
+ // Don't forget the last token
273
+ if (currentToken?.name) {
274
+ results.push({
275
+ name: currentToken.name,
276
+ value: getTokenValue(currentToken.name),
277
+ useCase: currentToken.useCase || descriptionLines.join(' '),
278
+ rules: currentToken.rules || '',
279
+ group: currentToken.group || '',
280
+ })
281
+ }
282
+
283
+ return results
284
+ }
285
+
286
+ // Get token value from the loaded tokens
287
+ function getTokenValue(tokenName: string): string {
288
+ const found = tokens.find(token => token.name === tokenName)
289
+ return found ? String(found.value) : ''
290
+ }
291
+
292
+ // Human-readable display labels for canonical group prefixes
293
+ const GROUP_LABELS: Record<string, string> = {
294
+ bgColor: 'Background Color',
295
+ fgColor: 'Foreground Color',
296
+ borderColor: 'Border Color',
297
+ border: 'Border',
298
+ shadow: 'Shadow',
299
+ focus: 'Focus',
300
+ color: 'Color',
301
+ borderWidth: 'Border Width',
302
+ borderRadius: 'Border Radius',
303
+ boxShadow: 'Box Shadow',
304
+ controlStack: 'Control Stack',
305
+ fontStack: 'Font Stack',
306
+ outline: 'Outline',
307
+ text: 'Text',
308
+ control: 'Control',
309
+ overlay: 'Overlay',
310
+ stack: 'Stack',
311
+ spinner: 'Spinner',
312
+ }
313
+
314
+ // Get canonical group prefix from token name
315
+ function getGroupFromName(name: string): string {
316
+ return name.split('-')[0]
317
+ }
318
+
319
+ // Build complete token list from JSON (includes all tokens, not just those with guidelines)
320
+ function buildAllTokens(guidelinesTokens: TokenWithGuidelines[]): TokenWithGuidelines[] {
321
+ const guidelinesMap = new Map(guidelinesTokens.map(t => [t.name, t]))
322
+
323
+ // Include theme tokens AND size/typography/border tokens
324
+ const allSourceTokens = [
325
+ ...categories.base.motion,
326
+ ...categories.base.size,
327
+ ...categories.base.typography,
328
+ ...categories.functional.themes.light,
329
+ ...categories.functional.size,
330
+ ...categories.functional.sizeCoarse,
331
+ ...categories.functional.sizeFine,
332
+ ...categories.functional.border,
333
+ ...categories.functional.radius,
334
+ ...categories.functional.typography,
335
+ ]
336
+
337
+ const allTokens: TokenWithGuidelines[] = []
338
+ const seen = new Set<string>()
339
+
340
+ for (const token of allSourceTokens) {
341
+ if (seen.has(token.name)) continue
342
+ seen.add(token.name)
343
+
344
+ const existing = guidelinesMap.get(token.name)
345
+ if (existing) {
346
+ allTokens.push(existing)
347
+ } else {
348
+ allTokens.push({
349
+ name: token.name,
350
+ value: String(token.value),
351
+ useCase: '',
352
+ rules: '',
353
+ group: getGroupFromName(token.name),
354
+ })
355
+ }
356
+ }
357
+
358
+ return allTokens
359
+ }
360
+
361
+ // Find tokens matching query and optional group filter
362
+ function findTokens(allTokens: TokenWithGuidelines[], query: string, group?: string): TokenWithGuidelines[] {
363
+ const lowerQuery = query.toLowerCase()
364
+ const lowerGroup = group?.toLowerCase()
365
+
366
+ return allTokens.filter(token => {
367
+ // Filter by group if provided - exact match only
368
+ if (lowerGroup && token.group.toLowerCase() !== lowerGroup) {
369
+ return false
370
+ }
371
+
372
+ // Fuzzy match against name, useCase, and rules
373
+ const searchText = `${token.name} ${token.useCase} ${token.rules}`.toLowerCase()
374
+ return searchText.includes(lowerQuery)
375
+ })
376
+ }
377
+
378
+ // Expand token patterns like [accent, danger] into multiple tokens
379
+ function expandTokenPattern(token: TokenWithGuidelines): TokenWithGuidelines[] {
380
+ const bracketRegex = /\[(.*?)\]/
381
+ const match = token.name.match(bracketRegex)
382
+
383
+ if (!match) return [token]
384
+
385
+ const variants = match[1].split(',').map((s: string) => s.trim())
386
+ return variants.map(variant => ({
387
+ ...token,
388
+ name: token.name.replace(bracketRegex, variant),
389
+ }))
390
+ }
391
+
392
+ // Load and parse token guidelines, then build complete token list
393
+ function loadAllTokensWithGuidelines(): TokenWithGuidelines[] {
394
+ try {
395
+ const specMarkdown = loadDesignTokensSpec()
396
+ const specTokens = parseDesignTokensSpec(specMarkdown)
397
+ return buildAllTokens(specTokens)
398
+ } catch {
399
+ // DESIGN_TOKENS_SPEC.md not available in this version of @primer/primitives
400
+ return buildAllTokens([])
401
+ }
402
+ }
403
+
404
+ // Load the design tokens guide (logic, rules, patterns, golden examples)
405
+ function loadDesignTokensGuide(): string {
406
+ const require = createRequire(import.meta.url)
407
+ const guidePath = require.resolve('@primer/primitives/DESIGN_TOKENS_GUIDE.md')
408
+ return readFileSync(guidePath, 'utf-8')
409
+ }
410
+
411
+ // Load the design tokens spec (token dictionary with use cases and rules)
412
+ function loadDesignTokensSpec(): string {
413
+ const require = createRequire(import.meta.url)
414
+ const specPath = require.resolve('@primer/primitives/DESIGN_TOKENS_SPEC.md')
415
+ return readFileSync(specPath, 'utf-8')
416
+ }
417
+
418
+ // Get design token specifications text with dynamic group information
419
+ function getDesignTokenSpecsText(groups: TokenGroups): string {
420
+ return `
421
+ # Design Token Specifications
422
+
423
+ ## 1. Core Rule & Enforcement
424
+ - **Expert Mode**: You are a CSS expert. NEVER use raw values (hex, px, etc.). Only use tokens.
425
+ - **Shorthand**: MUST use shorthand tokens (e.g., \`font: var(...)\`). NEVER split font-size/weight.
426
+ - **States**: MUST define 5 states: Rest, Hover, Focus-visible, Active, Disabled.
427
+ - **Safety**: If unsure of a token name, suffix with \`/* check-token */\`.
428
+ - **Focus States**: When implementing :focus-visible, you MUST use both:
429
+ - outline: var(--focus-outline)
430
+ - outline-offset: var(--outline-focus-offset)
431
+
432
+ ## 2. Typography Constraints (STRICT)
433
+ - **Body Only**: Only the \`body\` group supports size suffixes (e.g., \`body-small\`, \`body-medium\`).
434
+ - **Static Shorthands**: The following groups do NOT support size suffixes. Use the base shorthand only:
435
+ - **caption**: \`var(--text-caption-shorthand)\` (NEVER add -medium or -small)
436
+ - **display**: \`var(--text-display-shorthand)\`
437
+ - **codeBlock**: \`var(--text-codeBlock-shorthand)\`
438
+ - **codeInline**: \`var(--text-codeInline-shorthand)\`
439
+
440
+ ## 3. Logic Matrix: Color Pairings (CRITICAL)
441
+ | Background Token | Foreground Token | Requirement |
442
+ | :--- | :--- | :--- |
443
+ | --bgColor-*-emphasis | --fgColor-onEmphasis | MUST pair |
444
+ | --bgColor-*-muted | --fgColor-{semantic} | MUST match semantic |
445
+ | --bgColor-default | --fgColor-default | Standard pairing |
446
+ | --bgColor-muted | --fgColor-default | NEVER use fgColor-muted |
447
+
448
+ ## 4. Semantic Intent Key
449
+ Use these for \`find_tokens\` or to map natural language to groups:
450
+ - **Search Aliases**: Map "background" -> \`bgColor\`, "foreground" -> \`fgColor\`, "typography/font" -> \`text\`, "padding/margin" -> \`stack\`, "radius" -> \`borderRadius\`, "shadow/elevation" -> \`overlay\`.
451
+ - **danger**: Errors/Destructive | **success**: Positive/Done
452
+ - **attention**: Warning/Pending | **accent**: Interactive/Selected
453
+
454
+ ## 5. Available Token Groups
455
+ Use these names in \`find_tokens(group: "...")\`:
456
+ - **Semantic**: ${groups.semantic.map(g => g.name).join(', ')}
457
+ - **Components**: ${groups.component.map(g => g.name).join(', ')}
458
+
459
+ ## 6. Optimization Strategy (MANDATORY)
460
+ - **STOP**: Do not call \`find_tokens\` repeatedly for individual properties.
461
+ - **GO**: Use \`get_token_group_bundle\` to fetch relevant groups at once.
462
+ - *Example for Button*: \`get_token_group_bundle(groups: ["control", "button"])\`
463
+ - *Note*: \`control\` is for form inputs; \`button\` is for triggers.
464
+
465
+ ## 7. Token Bundle Recipes (Recommended)
466
+ - **Forms/Inputs**: \`["control", "focus", "outline", "text", "borderRadius"]\`
467
+ - **Modals/Dialogs**: \`["overlay", "shadow", "outline", "borderRadius", "bgColor"]\`
468
+ - **Data Tables**: \`["stack", "borderColor", "text", "bgColor"]\`
469
+ `.trim()
470
+ }
471
+
472
+ // Get token usage patterns text (static golden examples)
473
+ function getTokenUsagePatternsText(): string {
474
+ return `
475
+ # Design Token Reference Examples
476
+
477
+ > **CRITICAL FOR AI**: To implement the examples below, DO NOT search for tokens one-by-one.
478
+ > Use \`get_token_group_bundle(groups: ["control", "stack", "focus", "borderRadius"])\` to fetch the required token values in a single call.
479
+
480
+ ---
481
+
482
+ ## 1. Interaction Pattern: The Primary Button
483
+ *Demonstrates: 5 states, color pairing, typography shorthand, and motion.*
484
+
485
+ \`\`\`css
486
+ .btn-primary {
487
+ /* Logic: Use control tokens for interactive elements */
488
+ background-color: var(--control-bgColor-rest);
489
+ color: var(--fgColor-default);
490
+ font: var(--text-body-shorthand-medium); /* MUST use shorthand */
491
+
492
+ /* Scale: DEFAULT is medium/normal */
493
+ padding-block: var(--control-medium-paddingBlock);
494
+ padding-inline: var(--control-medium-paddingInline-normal);
495
+ border: none;
496
+ border-radius: var(--borderRadius-medium);
497
+ cursor: pointer;
498
+
499
+ /* Motion: MUST be <300ms */
500
+ transition: background-color 150ms ease, transform 100ms ease;
501
+ }
502
+
503
+ .btn-primary:hover {
504
+ background-color: var(--control-bgColor-hover);
505
+ }
506
+
507
+ .btn-primary:focus-visible {
508
+ outline: var(--focus-outline);
509
+ outline-offset: var(--outline-focus-offset);
510
+ }
511
+
512
+ .btn-primary:active {
513
+ background-color: var(--control-bgColor-active);
514
+ transform: scale(0.98);
515
+ }
516
+
517
+ .btn-primary:disabled {
518
+ /* Logic: MUST pair bgColor-disabled with fgColor-disabled */
519
+ background-color: var(--bgColor-disabled);
520
+ color: var(--fgColor-disabled);
521
+ cursor: not-allowed;
522
+ }
523
+ \`\`\`
524
+
525
+ ---
526
+
527
+ ## 2. Layout Pattern: Vertical Stack
528
+ *Demonstrates: Layout spacing rules and matching padding density.*
529
+
530
+ \`\`\`css
531
+ .card-stack {
532
+ display: flex;
533
+ flex-direction: column;
534
+
535
+ /* Logic: Use stack tokens for layout spacing */
536
+ gap: var(--stack-gap-normal);
537
+ padding: var(--stack-padding-normal);
538
+
539
+ background-color: var(--bgColor-default);
540
+ border: 1px solid var(--borderColor-default);
541
+ border-radius: var(--borderRadius-large);
542
+ }
543
+
544
+ /* Logic: Matching padding density to purpose */
545
+ .card-header {
546
+ padding-block-end: var(--stack-gap-condensed);
547
+ border-bottom: 1px solid var(--borderColor-muted);
548
+ }
549
+ \`\`\`
550
+
551
+ ---
552
+
553
+ ## Implementation Rules for AI:
554
+ 1. **Shorthand First**: Always use \`font: var(...)\` rather than splitting size/weight.
555
+ 2. **States**: Never implement a button without all 5 states.
556
+ 3. **Spacing**: Use \`control-\` tokens for the component itself and \`stack-\` tokens for the container/layout.
557
+ 4. **Motion**: Always include the \`prefers-reduced-motion\` media query to set transitions to \`none\`.
558
+ \`\`\`css
559
+ @media (prefers-reduced-motion: reduce) {
560
+ .btn-primary {
561
+ transition: none;
562
+ }
563
+ }
564
+ \`\`\`
565
+ `.trim()
566
+ }
567
+
568
+ // Search tokens with keyword matching and optional group filter
569
+ // Returns expanded tokens (patterns like [accent, danger] are expanded) filtered by query
570
+ function searchTokens(allTokens: TokenWithGuidelines[], query: string, group?: string): TokenWithGuidelines[] {
571
+ // 1. Flatten and expand all patterns first (e.g., [accent, danger])
572
+ const expandedTokens = allTokens.flatMap(expandTokenPattern)
573
+
574
+ // 2. Prepare keywords and group filter
575
+ const keywords = query
576
+ .toLowerCase()
577
+ .split(/\s+/)
578
+ .filter(k => k.length > 0)
579
+
580
+ // 3. Perform filtered search with keyword splitting (Logical AND)
581
+ return expandedTokens.filter(token => {
582
+ // Combine all relevant metadata into one searchable string
583
+ const searchableText = `${token.name} ${token.useCase} ${token.rules} ${token.group}`.toLowerCase()
584
+
585
+ // Ensure EVERY keyword in the query exists somewhere in this token's metadata
586
+ const matchesKeywords = keywords.every(word => searchableText.includes(word))
587
+
588
+ const matchesGroup = !group || tokenMatchesGroup(token, group)
589
+
590
+ return matchesKeywords && matchesGroup
591
+ })
592
+ }
593
+
594
+ // Alias map: fuzzy/human-readable names → canonical token name prefix
595
+ const GROUP_ALIASES: Record<string, string> = {
596
+ // Identity mappings (canonical prefixes, lowercased key)
597
+ bgcolor: 'bgColor',
598
+ fgcolor: 'fgColor',
599
+ bordercolor: 'borderColor',
600
+ border: 'border',
601
+ shadow: 'shadow',
602
+ focus: 'focus',
603
+ color: 'color',
604
+ button: 'button',
605
+ control: 'control',
606
+ overlay: 'overlay',
607
+ borderradius: 'borderRadius',
608
+ boxshadow: 'boxShadow',
609
+ fontstack: 'fontStack',
610
+ spinner: 'spinner',
611
+
612
+ // Fuzzy aliases
613
+ background: 'bgColor',
614
+ backgroundcolor: 'bgColor',
615
+ bg: 'bgColor',
616
+ foreground: 'fgColor',
617
+ foregroundcolor: 'fgColor',
618
+ textcolor: 'fgColor',
619
+ fg: 'fgColor',
620
+ radius: 'borderRadius',
621
+ rounded: 'borderRadius',
622
+ elevation: 'overlay',
623
+ depth: 'overlay',
624
+ btn: 'button',
625
+ typography: 'text',
626
+ font: 'text',
627
+ text: 'text',
628
+
629
+ // Layout & Spacing
630
+ stack: 'stack',
631
+ controlstack: 'controlStack',
632
+ padding: 'stack',
633
+ margin: 'stack',
634
+ gap: 'stack',
635
+ spacing: 'stack',
636
+ layout: 'stack',
637
+
638
+ // State & Interaction
639
+ offset: 'focus',
640
+ outline: 'outline',
641
+ ring: 'focus',
642
+
643
+ // Decoration & Borders
644
+ borderwidth: 'borderWidth',
645
+ line: 'borderColor',
646
+ stroke: 'borderColor',
647
+ separator: 'borderColor',
648
+ }
649
+
650
+ // Match a token against a resolved group by checking both the token name prefix and the group label
651
+ function tokenMatchesGroup(token: TokenWithGuidelines, resolvedGroup: string): boolean {
652
+ const rg = resolvedGroup.toLowerCase()
653
+ const tokenPrefix = token.name.split('-')[0].toLowerCase()
654
+ const tokenGroup = token.group.toLowerCase()
655
+ return tokenPrefix === rg || tokenGroup === rg
656
+ }
657
+
658
+ // Group tokens by their group property and format as Markdown
659
+ function formatBundle(bundleTokens: TokenWithGuidelines[]): string {
660
+ const grouped = bundleTokens.reduce<Record<string, TokenWithGuidelines[]>>((acc, token) => {
661
+ const group = GROUP_LABELS[token.group] || token.group || 'Ungrouped'
662
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
663
+ if (!acc[group]) acc[group] = []
664
+ acc[group].push(token)
665
+ return acc
666
+ }, {})
667
+
668
+ return Object.entries(grouped)
669
+ .map(([group, groupTokens]) => {
670
+ const tokenList = groupTokens
671
+ .map(t => {
672
+ const nameLabel = t.value ? `\`${t.name}\` → \`${t.value}\`` : `\`${t.name}\``
673
+ return `- ${nameLabel}\n - **U**: ${t.useCase || '(none)'}\n - **R**: ${t.rules || '(none)'}`
674
+ })
675
+ .join('\n')
676
+ return `## ${group}\n\n${tokenList}`
677
+ })
678
+ .join('\n\n---\n\n')
679
+ }
680
+
681
+ /**
682
+ * Generates a sorted, unique list of group names from the current token cache.
683
+ * Used for "Healing" error messages and the Design System Search Map.
684
+ */
685
+ function getValidGroupsList(validTokens: TokenWithGuidelines[]): string {
686
+ if (validTokens.length === 0) {
687
+ return 'No groups available.'
688
+ }
689
+
690
+ // 1. Extract unique group names
691
+ const uniqueGroups = Array.from(new Set(validTokens.map(t => t.group)))
692
+
693
+ // 2. Sort alphabetically for consistency
694
+ uniqueGroups.sort((a, b) => a.localeCompare(b))
695
+
696
+ // 3. Return as a formatted Markdown string with backticks
697
+ return uniqueGroups.map(g => `\`${g}\``).join(', ')
698
+ }
699
+
700
+ // Usage Guidance Hints
701
+ const groupHints: Record<string, string> = {
702
+ control: '`control` tokens are for form inputs/checkboxes. For buttons, use the `button` group.',
703
+ button: '`button` tokens are for standard triggers. For form-fields, see the `control` group.',
704
+ text: 'STRICT: The following typography groups do NOT support size suffixes (-small, -medium, -large): `caption`, `display`, `codeBlock`, and `codeInline`. Use the base shorthand name only (e.g., --text-codeBlock-shorthand).',
705
+ fgColor: 'Use `fgColor` for text. For borders, use `borderColor`.',
706
+ borderWidth:
707
+ '`borderWidth` only has sizing values (thin, thick, thicker). For border *colors*, use the `borderColor` or `border` group.',
708
+ }
709
+
710
+ export {
711
+ parseDesignTokensSpec,
712
+ findTokens,
713
+ buildAllTokens,
714
+ expandTokenPattern,
715
+ loadAllTokensWithGuidelines,
716
+ loadDesignTokensGuide,
717
+ loadDesignTokensSpec,
718
+ getDesignTokenSpecsText,
719
+ getTokenUsagePatternsText,
720
+ getValidGroupsList,
721
+ searchTokens,
722
+ formatBundle,
723
+ groupHints,
724
+ GROUP_ALIASES,
725
+ GROUP_LABELS,
726
+ tokenMatchesGroup,
727
+ type TokenWithGuidelines,
728
+ }