@clarity-contrib/tailwindcss-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Conversion Service for TailwindCSS MCP Server
3
+ * Converts traditional CSS to TailwindCSS utilities using CSS parsing
4
+ */
5
+ import { ServiceError } from './base.js';
6
+ import * as csstree from 'css-tree';
7
+ export class ConversionService {
8
+ propertyMap = new Map();
9
+ tailwindUtilities = new Set();
10
+ async initialize() {
11
+ this.setupPropertyMappings();
12
+ this.setupTailwindUtilities();
13
+ }
14
+ async cleanup() {
15
+ this.propertyMap.clear();
16
+ this.tailwindUtilities.clear();
17
+ }
18
+ /**
19
+ * Convert CSS to TailwindCSS utilities
20
+ */
21
+ async convertCSS(params) {
22
+ try {
23
+ const { css, mode = 'classes' } = params;
24
+ if (!css.trim()) {
25
+ return {
26
+ tailwindClasses: '',
27
+ unsupportedStyles: [],
28
+ suggestions: ['Provide some CSS to convert']
29
+ };
30
+ }
31
+ // Check for obviously malformed CSS before parsing
32
+ if (this.isMalformedCSS(css)) {
33
+ throw new ServiceError('Invalid CSS syntax', 'ConversionService', 'convertCSS');
34
+ }
35
+ const ast = this.parseCSS(css);
36
+ const conversions = this.extractStylesFromAST(ast);
37
+ return this.formatResult(conversions, mode);
38
+ }
39
+ catch (error) {
40
+ if (error instanceof ServiceError) {
41
+ throw error;
42
+ }
43
+ throw new ServiceError('Failed to convert CSS to TailwindCSS', 'ConversionService', 'convertCSS', error);
44
+ }
45
+ }
46
+ /**
47
+ * Parse CSS string into AST
48
+ */
49
+ parseCSS(css) {
50
+ try {
51
+ return csstree.parse(css);
52
+ }
53
+ catch (error) {
54
+ // Check for common invalid CSS patterns that should throw errors
55
+ if (css.includes('{') && !css.includes('}')) {
56
+ throw new ServiceError('Invalid CSS syntax', 'ConversionService', 'parseCSS', error);
57
+ }
58
+ // For other parse errors, also throw
59
+ throw new ServiceError('Invalid CSS syntax', 'ConversionService', 'parseCSS', error);
60
+ }
61
+ }
62
+ /**
63
+ * Extract styles from CSS AST
64
+ */
65
+ extractStylesFromAST(ast) {
66
+ const result = {
67
+ utilities: [],
68
+ unsupported: [],
69
+ custom: []
70
+ };
71
+ csstree.walk(ast, (node, item, list) => {
72
+ if (node.type === 'Rule') {
73
+ this.processRule(node, result);
74
+ }
75
+ });
76
+ return result;
77
+ }
78
+ /**
79
+ * Process a CSS rule
80
+ */
81
+ processRule(rule, result) {
82
+ if (!rule.block || rule.block.type !== 'Block')
83
+ return;
84
+ const declarations = [];
85
+ csstree.walk(rule.block, (node) => {
86
+ if (node.type === 'Declaration') {
87
+ const declaration = node;
88
+ declarations.push({
89
+ property: declaration.property,
90
+ value: csstree.generate(declaration.value)
91
+ });
92
+ }
93
+ });
94
+ declarations.forEach(({ property, value }) => {
95
+ this.processDeclaration(property, value, result);
96
+ });
97
+ }
98
+ /**
99
+ * Process a CSS declaration
100
+ */
101
+ processDeclaration(property, value, result) {
102
+ const mapping = this.propertyMap.get(property);
103
+ if (!mapping) {
104
+ result.unsupported.push(`${property}: ${value}`);
105
+ return;
106
+ }
107
+ const tailwindClass = this.convertDeclaration(property, value, mapping);
108
+ if (tailwindClass) {
109
+ // Check if it's a space-separated list of utilities (like "py-4 px-8")
110
+ if (tailwindClass.includes(' ')) {
111
+ const utilities = tailwindClass.split(' ');
112
+ const validUtilities = utilities.filter(util => this.tailwindUtilities.has(util));
113
+ if (validUtilities.length === utilities.length) {
114
+ result.utilities.push(...validUtilities);
115
+ }
116
+ else {
117
+ result.custom.push(`${property}: ${value} → Consider creating custom utility: ${tailwindClass}`);
118
+ }
119
+ }
120
+ else if (this.tailwindUtilities.has(tailwindClass)) {
121
+ result.utilities.push(tailwindClass);
122
+ }
123
+ else {
124
+ result.custom.push(`${property}: ${value} → Consider creating custom utility: ${tailwindClass}`);
125
+ }
126
+ }
127
+ else {
128
+ result.unsupported.push(`${property}: ${value}`);
129
+ }
130
+ }
131
+ /**
132
+ * Convert a CSS declaration to TailwindCSS class
133
+ */
134
+ convertDeclaration(property, value, mapping) {
135
+ const cleanValue = value.trim();
136
+ // Handle specific value mappings
137
+ if (mapping.valueMap && mapping.valueMap.has(cleanValue)) {
138
+ return mapping.valueMap.get(cleanValue);
139
+ }
140
+ // Handle pattern-based mappings
141
+ if (mapping.pattern) {
142
+ return this.applyPattern(cleanValue, mapping.pattern, mapping.prefix);
143
+ }
144
+ // Handle unit-based mappings
145
+ if (mapping.unitMapping) {
146
+ return this.convertWithUnits(cleanValue, mapping.prefix);
147
+ }
148
+ return null;
149
+ }
150
+ /**
151
+ * Apply pattern-based conversion
152
+ */
153
+ applyPattern(value, pattern, prefix) {
154
+ const match = value.match(pattern);
155
+ if (match) {
156
+ return `${prefix}-${match[1] || value}`;
157
+ }
158
+ return null;
159
+ }
160
+ /**
161
+ * Convert values with unit handling
162
+ */
163
+ convertWithUnits(value, prefix) {
164
+ // Handle shorthand values like "1rem 2rem" for padding/margin
165
+ if (value.includes(' ')) {
166
+ const values = value.split(/\s+/);
167
+ if (values.length === 2) {
168
+ if (values[0] === values[1]) {
169
+ // Same value for all sides (e.g., "1rem 1rem")
170
+ return this.convertSingleValue(values[0], prefix);
171
+ }
172
+ else {
173
+ // Different vertical and horizontal (e.g., "1rem 2rem")
174
+ const vertical = this.convertSingleValue(values[0], prefix.charAt(0) + 'y');
175
+ const horizontal = this.convertSingleValue(values[1], prefix.charAt(0) + 'x');
176
+ return [vertical, horizontal].filter(Boolean).join(' ');
177
+ }
178
+ }
179
+ else if (values.length === 4) {
180
+ // All sides specified
181
+ const top = this.convertSingleValue(values[0], prefix.charAt(0) + 't');
182
+ const right = this.convertSingleValue(values[1], prefix.charAt(0) + 'r');
183
+ const bottom = this.convertSingleValue(values[2], prefix.charAt(0) + 'b');
184
+ const left = this.convertSingleValue(values[3], prefix.charAt(0) + 'l');
185
+ return [top, right, bottom, left].filter(Boolean).join(' ');
186
+ }
187
+ }
188
+ return this.convertSingleValue(value, prefix);
189
+ }
190
+ /**
191
+ * Convert a single value with unit handling
192
+ */
193
+ convertSingleValue(value, prefix) {
194
+ // Handle numeric values with units
195
+ const numericMatch = value.match(/^(-?\d*\.?\d+)(px|rem|em|%|vh|vw)?$/);
196
+ if (numericMatch) {
197
+ const [, num, unit] = numericMatch;
198
+ const numValue = parseFloat(num);
199
+ // Convert to Tailwind scale
200
+ if (unit === 'px') {
201
+ const remValue = numValue / 16; // Assuming 16px = 1rem
202
+ const tailwindValue = this.findClosestTailwindValue(remValue);
203
+ return `${prefix}-${tailwindValue}`;
204
+ }
205
+ if (unit === 'rem' || !unit) {
206
+ const tailwindValue = this.findClosestTailwindValue(numValue);
207
+ return `${prefix}-${tailwindValue}`;
208
+ }
209
+ if (unit === '%') {
210
+ const percentValue = this.convertPercentToTailwind(numValue);
211
+ return percentValue ? `${prefix}-${percentValue}` : null;
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+ /**
217
+ * Find closest Tailwind spacing value
218
+ */
219
+ findClosestTailwindValue(remValue) {
220
+ const spacingScale = [
221
+ { value: 0, class: '0' },
222
+ { value: 0.125, class: '0.5' },
223
+ { value: 0.25, class: '1' },
224
+ { value: 0.375, class: '1.5' },
225
+ { value: 0.5, class: '2' },
226
+ { value: 0.625, class: '2.5' },
227
+ { value: 0.75, class: '3' },
228
+ { value: 0.875, class: '3.5' },
229
+ { value: 1, class: '4' },
230
+ { value: 1.25, class: '5' },
231
+ { value: 1.5, class: '6' },
232
+ { value: 1.75, class: '7' },
233
+ { value: 2, class: '8' },
234
+ { value: 2.25, class: '9' },
235
+ { value: 2.5, class: '10' },
236
+ { value: 2.75, class: '11' },
237
+ { value: 3, class: '12' },
238
+ { value: 3.5, class: '14' },
239
+ { value: 4, class: '16' },
240
+ { value: 5, class: '20' },
241
+ { value: 6, class: '24' }
242
+ ];
243
+ let closest = spacingScale[0];
244
+ let minDiff = Math.abs(remValue - closest.value);
245
+ for (const scale of spacingScale) {
246
+ const diff = Math.abs(remValue - scale.value);
247
+ if (diff < minDiff) {
248
+ minDiff = diff;
249
+ closest = scale;
250
+ }
251
+ }
252
+ return closest.class;
253
+ }
254
+ /**
255
+ * Convert percentage to Tailwind fraction
256
+ */
257
+ convertPercentToTailwind(percent) {
258
+ const fractions = [
259
+ { percent: 8.333333, class: '1/12' },
260
+ { percent: 16.666667, class: '1/6' },
261
+ { percent: 20, class: '1/5' },
262
+ { percent: 25, class: '1/4' },
263
+ { percent: 33.333333, class: '1/3' },
264
+ { percent: 41.666667, class: '5/12' },
265
+ { percent: 50, class: '1/2' },
266
+ { percent: 58.333333, class: '7/12' },
267
+ { percent: 66.666667, class: '2/3' },
268
+ { percent: 75, class: '3/4' },
269
+ { percent: 83.333333, class: '5/6' },
270
+ { percent: 91.666667, class: '11/12' },
271
+ { percent: 100, class: 'full' }
272
+ ];
273
+ let closest = fractions[0];
274
+ let minDiff = Math.abs(percent - closest.percent);
275
+ for (const fraction of fractions) {
276
+ const diff = Math.abs(percent - fraction.percent);
277
+ if (diff < minDiff) {
278
+ minDiff = diff;
279
+ closest = fraction;
280
+ }
281
+ }
282
+ return minDiff < 2 ? closest.class : null; // Only return if close enough
283
+ }
284
+ /**
285
+ * Format the conversion result
286
+ */
287
+ formatResult(conversions, mode) {
288
+ const result = {
289
+ tailwindClasses: '',
290
+ unsupportedStyles: conversions.unsupported,
291
+ suggestions: [],
292
+ customUtilities: conversions.custom
293
+ };
294
+ // Add specific suggestions first
295
+ if (conversions.unsupported.length > 0) {
296
+ if (!result.suggestions)
297
+ result.suggestions = [];
298
+ result.suggestions.push("Some CSS properties don't have direct TailwindCSS equivalents. Consider using arbitrary values like [property:value]");
299
+ }
300
+ if (conversions.custom.length > 0) {
301
+ if (!result.suggestions)
302
+ result.suggestions = [];
303
+ result.suggestions.push("Some values are outside Tailwind's default scale. Consider extending your Tailwind config or using arbitrary values");
304
+ }
305
+ if (conversions.utilities.length === 0) {
306
+ // Only add the generic message if no specific suggestions were added
307
+ if (!result.suggestions || result.suggestions.length === 0) {
308
+ if (!result.suggestions)
309
+ result.suggestions = [];
310
+ result.suggestions.push('No direct TailwindCSS equivalents found for the provided CSS');
311
+ }
312
+ return result;
313
+ }
314
+ switch (mode) {
315
+ case 'inline':
316
+ result.tailwindClasses = `class="${conversions.utilities.join(' ')}"`;
317
+ break;
318
+ case 'component':
319
+ result.tailwindClasses = `.component {\n @apply ${conversions.utilities.join(' ')};\n}`;
320
+ break;
321
+ default: // 'classes'
322
+ result.tailwindClasses = conversions.utilities.join(' ');
323
+ }
324
+ return result;
325
+ }
326
+ /**
327
+ * Setup property mappings from CSS to TailwindCSS
328
+ */
329
+ setupPropertyMappings() {
330
+ // Display properties
331
+ this.propertyMap.set('display', {
332
+ prefix: '',
333
+ valueMap: new Map([
334
+ ['block', 'block'],
335
+ ['inline-block', 'inline-block'],
336
+ ['inline', 'inline'],
337
+ ['flex', 'flex'],
338
+ ['inline-flex', 'inline-flex'],
339
+ ['table', 'table'],
340
+ ['inline-table', 'inline-table'],
341
+ ['table-caption', 'table-caption'],
342
+ ['table-cell', 'table-cell'],
343
+ ['table-column', 'table-column'],
344
+ ['table-column-group', 'table-column-group'],
345
+ ['table-footer-group', 'table-footer-group'],
346
+ ['table-header-group', 'table-header-group'],
347
+ ['table-row-group', 'table-row-group'],
348
+ ['table-row', 'table-row'],
349
+ ['flow-root', 'flow-root'],
350
+ ['grid', 'grid'],
351
+ ['inline-grid', 'inline-grid'],
352
+ ['contents', 'contents'],
353
+ ['list-item', 'list-item'],
354
+ ['hidden', 'hidden'],
355
+ ['none', 'hidden']
356
+ ])
357
+ });
358
+ // Position properties
359
+ this.propertyMap.set('position', {
360
+ prefix: '',
361
+ valueMap: new Map([
362
+ ['static', 'static'],
363
+ ['fixed', 'fixed'],
364
+ ['absolute', 'absolute'],
365
+ ['relative', 'relative'],
366
+ ['sticky', 'sticky']
367
+ ])
368
+ });
369
+ // Spacing properties
370
+ this.propertyMap.set('margin', { prefix: 'm', unitMapping: true });
371
+ this.propertyMap.set('margin-top', { prefix: 'mt', unitMapping: true });
372
+ this.propertyMap.set('margin-right', { prefix: 'mr', unitMapping: true });
373
+ this.propertyMap.set('margin-bottom', { prefix: 'mb', unitMapping: true });
374
+ this.propertyMap.set('margin-left', { prefix: 'ml', unitMapping: true });
375
+ this.propertyMap.set('padding', { prefix: 'p', unitMapping: true });
376
+ this.propertyMap.set('padding-top', { prefix: 'pt', unitMapping: true });
377
+ this.propertyMap.set('padding-right', { prefix: 'pr', unitMapping: true });
378
+ this.propertyMap.set('padding-bottom', { prefix: 'pb', unitMapping: true });
379
+ this.propertyMap.set('padding-left', { prefix: 'pl', unitMapping: true });
380
+ // Width and Height
381
+ this.propertyMap.set('width', { prefix: 'w', unitMapping: true });
382
+ this.propertyMap.set('height', { prefix: 'h', unitMapping: true });
383
+ this.propertyMap.set('min-width', { prefix: 'min-w', unitMapping: true });
384
+ this.propertyMap.set('max-width', { prefix: 'max-w', unitMapping: true });
385
+ this.propertyMap.set('min-height', { prefix: 'min-h', unitMapping: true });
386
+ this.propertyMap.set('max-height', { prefix: 'max-h', unitMapping: true });
387
+ // Typography
388
+ this.propertyMap.set('font-size', { prefix: 'text', unitMapping: true });
389
+ this.propertyMap.set('font-weight', {
390
+ prefix: 'font',
391
+ valueMap: new Map([
392
+ ['100', 'font-thin'],
393
+ ['200', 'font-extralight'],
394
+ ['300', 'font-light'],
395
+ ['400', 'font-normal'],
396
+ ['500', 'font-medium'],
397
+ ['600', 'font-semibold'],
398
+ ['700', 'font-bold'],
399
+ ['800', 'font-extrabold'],
400
+ ['900', 'font-black'],
401
+ ['normal', 'font-normal'],
402
+ ['bold', 'font-bold']
403
+ ])
404
+ });
405
+ this.propertyMap.set('text-align', {
406
+ prefix: 'text',
407
+ valueMap: new Map([
408
+ ['left', 'text-left'],
409
+ ['center', 'text-center'],
410
+ ['right', 'text-right'],
411
+ ['justify', 'text-justify']
412
+ ])
413
+ });
414
+ // Colors
415
+ this.propertyMap.set('color', { prefix: 'text', pattern: /#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})/ });
416
+ this.propertyMap.set('background-color', { prefix: 'bg', pattern: /#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})/ });
417
+ this.propertyMap.set('border-color', { prefix: 'border', pattern: /#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})/ });
418
+ // Flexbox
419
+ this.propertyMap.set('flex-direction', {
420
+ prefix: 'flex',
421
+ valueMap: new Map([
422
+ ['row', 'flex-row'],
423
+ ['row-reverse', 'flex-row-reverse'],
424
+ ['column', 'flex-col'],
425
+ ['column-reverse', 'flex-col-reverse']
426
+ ])
427
+ });
428
+ this.propertyMap.set('justify-content', {
429
+ prefix: 'justify',
430
+ valueMap: new Map([
431
+ ['flex-start', 'justify-start'],
432
+ ['flex-end', 'justify-end'],
433
+ ['center', 'justify-center'],
434
+ ['space-between', 'justify-between'],
435
+ ['space-around', 'justify-around'],
436
+ ['space-evenly', 'justify-evenly']
437
+ ])
438
+ });
439
+ this.propertyMap.set('align-items', {
440
+ prefix: 'items',
441
+ valueMap: new Map([
442
+ ['flex-start', 'items-start'],
443
+ ['flex-end', 'items-end'],
444
+ ['center', 'items-center'],
445
+ ['baseline', 'items-baseline'],
446
+ ['stretch', 'items-stretch']
447
+ ])
448
+ });
449
+ }
450
+ /**
451
+ * Setup common TailwindCSS utilities for validation
452
+ */
453
+ setupTailwindUtilities() {
454
+ const utilities = [
455
+ // Display
456
+ 'block', 'inline-block', 'inline', 'flex', 'inline-flex', 'grid', 'inline-grid', 'hidden',
457
+ // Position
458
+ 'static', 'fixed', 'absolute', 'relative', 'sticky',
459
+ // Spacing (sample)
460
+ 'm-0', 'm-1', 'm-2', 'm-3', 'm-4', 'm-5', 'm-6', 'm-8', 'm-10', 'm-12', 'mb-8',
461
+ 'mt-0', 'mt-1', 'mt-2', 'mt-3', 'mt-4', 'mt-5', 'mt-6', 'mt-8', 'mt-10', 'mt-12',
462
+ 'mr-0', 'mr-1', 'mr-2', 'mr-3', 'mr-4', 'mr-5', 'mr-6', 'mr-8', 'mr-10', 'mr-12',
463
+ 'mb-0', 'mb-1', 'mb-2', 'mb-3', 'mb-4', 'mb-5', 'mb-6', 'mb-8', 'mb-10', 'mb-12',
464
+ 'ml-0', 'ml-1', 'ml-2', 'ml-3', 'ml-4', 'ml-5', 'ml-6', 'ml-8', 'ml-10', 'ml-12',
465
+ 'p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5', 'p-6', 'p-8', 'p-10', 'p-12',
466
+ 'px-0', 'px-1', 'px-2', 'px-3', 'px-4', 'px-5', 'px-6', 'px-8', 'px-10', 'px-12',
467
+ 'py-0', 'py-1', 'py-2', 'py-3', 'py-4', 'py-5', 'py-6', 'py-8', 'py-10', 'py-12',
468
+ 'pt-0', 'pt-1', 'pt-2', 'pt-3', 'pt-4', 'pt-5', 'pt-6', 'pt-8', 'pt-10', 'pt-12',
469
+ 'pr-0', 'pr-1', 'pr-2', 'pr-3', 'pr-4', 'pr-5', 'pr-6', 'pr-8', 'pr-10', 'pr-12',
470
+ 'pb-0', 'pb-1', 'pb-2', 'pb-3', 'pb-4', 'pb-5', 'pb-6', 'pb-8', 'pb-10', 'pb-12',
471
+ 'pl-0', 'pl-1', 'pl-2', 'pl-3', 'pl-4', 'pl-5', 'pl-6', 'pl-8', 'pl-10', 'pl-12',
472
+ // Width/Height (sample)
473
+ 'w-0', 'w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6', 'w-8', 'w-10', 'w-12', 'w-16', 'w-full', 'w-1/2', 'w-1/3', 'w-2/3', 'w-1/4', 'w-3/4',
474
+ 'h-0', 'h-1', 'h-2', 'h-3', 'h-4', 'h-5', 'h-6', 'h-8', 'h-10', 'h-12', 'h-16', 'h-full', 'h-screen',
475
+ // Typography
476
+ 'font-thin', 'font-light', 'font-normal', 'font-medium', 'font-semibold', 'font-bold', 'font-extrabold', 'font-black',
477
+ 'text-left', 'text-center', 'text-right', 'text-justify',
478
+ 'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl', 'text-4xl', 'text-5xl', 'text-6xl',
479
+ // Flexbox
480
+ 'flex-row', 'flex-row-reverse', 'flex-col', 'flex-col-reverse',
481
+ 'justify-start', 'justify-end', 'justify-center', 'justify-between', 'justify-around', 'justify-evenly',
482
+ 'items-start', 'items-end', 'items-center', 'items-baseline', 'items-stretch'
483
+ ];
484
+ utilities.forEach(utility => this.tailwindUtilities.add(utility));
485
+ }
486
+ /**
487
+ * Check if CSS is malformed
488
+ */
489
+ isMalformedCSS(css) {
490
+ // Check for unbalanced braces
491
+ const openBraces = (css.match(/{/g) || []).length;
492
+ const closeBraces = (css.match(/}/g) || []).length;
493
+ if (openBraces !== closeBraces) {
494
+ return true;
495
+ }
496
+ // Skip @media and @keyframes rules which are valid but complex
497
+ if (css.includes('@media') || css.includes('@keyframes') || css.includes('@supports')) {
498
+ return false;
499
+ }
500
+ // Check for selectors with opening brace but no closing brace on the same "rule"
501
+ const rules = css.split('}');
502
+ for (const rule of rules) {
503
+ const trimmedRule = rule.trim();
504
+ if (trimmedRule && trimmedRule.includes('{')) {
505
+ // This rule has an opening brace, check if it looks malformed
506
+ const afterBrace = trimmedRule.split('{')[1];
507
+ if (afterBrace && afterBrace.trim() && !afterBrace.includes(':') && !afterBrace.includes('@')) {
508
+ // Has content after brace but no colon (likely malformed property)
509
+ // But skip @ rules which are valid
510
+ return true;
511
+ }
512
+ }
513
+ }
514
+ return false;
515
+ }
516
+ }