cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
packages/kumo-figma/src/parsers/tailwind-theme-parser.ts
428lines · modecode
| 1 | /** |
| 2 | * Tailwind v4 Theme CSS Parser |
| 3 | * |
| 4 | * Parses the Tailwind v4 theme.css file from node_modules to extract |
| 5 | * default design token values. This allows drift detection tests to |
| 6 | * verify that hardcoded values in the Figma plugin match Tailwind's |
| 7 | * actual defaults. |
| 8 | * |
| 9 | * Source: node_modules/tailwindcss/theme.css |
| 10 | */ |
| 11 | |
| 12 | import { readFileSync } from "fs"; |
| 13 | import { join } from "path"; |
| 14 | |
| 15 | /** |
| 16 | * Resolve the path to Tailwind's theme.css |
| 17 | * Works with pnpm's node_modules structure |
| 18 | * Searches both local package, kumo package, and monorepo root |
| 19 | */ |
| 20 | export function getTailwindThemeCssPath(): string { |
| 21 | const { readdirSync, existsSync } = require("fs"); |
| 22 | |
| 23 | // Possible locations to search |
| 24 | const searchPaths = [ |
| 25 | // Local package node_modules |
| 26 | process.cwd(), |
| 27 | // Kumo package (sibling) |
| 28 | join(process.cwd(), "../kumo"), |
| 29 | // Monorepo root (from packages/kumo-figma -> ../..) |
| 30 | join(process.cwd(), "../.."), |
| 31 | ]; |
| 32 | |
| 33 | for (const basePath of searchPaths) { |
| 34 | // Try direct path first (standard node_modules) |
| 35 | const directPath = join(basePath, "node_modules/tailwindcss/theme.css"); |
| 36 | try { |
| 37 | readFileSync(directPath); |
| 38 | return directPath; |
| 39 | } catch { |
| 40 | // Ignore and try pnpm structure |
| 41 | } |
| 42 | |
| 43 | // Try pnpm path structure |
| 44 | const pnpmBasePath = join(basePath, "node_modules/.pnpm"); |
| 45 | if (existsSync(pnpmBasePath)) { |
| 46 | try { |
| 47 | const pnpmDirs = readdirSync(pnpmBasePath); |
| 48 | const tailwindDir = pnpmDirs.find((d: string) => |
| 49 | d.startsWith("tailwindcss@"), |
| 50 | ); |
| 51 | |
| 52 | if (tailwindDir) { |
| 53 | const pnpmThemePath = join( |
| 54 | pnpmBasePath, |
| 55 | tailwindDir, |
| 56 | "node_modules/tailwindcss/theme.css", |
| 57 | ); |
| 58 | try { |
| 59 | readFileSync(pnpmThemePath); |
| 60 | return pnpmThemePath; |
| 61 | } catch { |
| 62 | // Continue searching |
| 63 | } |
| 64 | } |
| 65 | } catch { |
| 66 | // Continue searching |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | throw new Error( |
| 72 | "Could not find tailwindcss/theme.css in node_modules. " + |
| 73 | "Ensure tailwindcss is installed in kumo package or monorepo root.", |
| 74 | ); |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Read and return the raw content of Tailwind's theme.css |
| 79 | */ |
| 80 | export function readTailwindThemeCss(): string { |
| 81 | const themePath = getTailwindThemeCssPath(); |
| 82 | return readFileSync(themePath, "utf-8"); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Convert rem value to pixels (assuming 16px base) |
| 87 | */ |
| 88 | export function remToPx(remValue: string): number { |
| 89 | const num = parseFloat(remValue.replace("rem", "")); |
| 90 | return Math.round(num * 16); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Parsed spacing configuration from Tailwind |
| 95 | */ |
| 96 | export type TailwindSpacing = { |
| 97 | /** Base spacing unit in rem (e.g., 0.25rem = 4px) */ |
| 98 | baseUnit: number; |
| 99 | /** Base spacing unit in pixels */ |
| 100 | baseUnitPx: number; |
| 101 | }; |
| 102 | |
| 103 | /** |
| 104 | * Extract the base spacing unit from theme.css |
| 105 | * Tailwind v4 uses --spacing: 0.25rem as the base unit |
| 106 | */ |
| 107 | export function parseSpacing(themeCss: string): TailwindSpacing { |
| 108 | const match = themeCss.match(/--spacing:\s*([\d.]+)rem/); |
| 109 | if (!match) { |
| 110 | throw new Error("Could not find --spacing in theme.css"); |
| 111 | } |
| 112 | |
| 113 | const baseUnit = parseFloat(match[1]); |
| 114 | return { |
| 115 | baseUnit, |
| 116 | baseUnitPx: remToPx(`${baseUnit}rem`), |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Calculate spacing value in pixels for a given Tailwind spacing key |
| 122 | * e.g., "1" -> 4px, "2" -> 8px, "3.5" -> 14px |
| 123 | */ |
| 124 | export function getSpacingPx(key: string, baseUnitPx: number): number { |
| 125 | const multiplier = parseFloat(key); |
| 126 | return Math.round(multiplier * baseUnitPx); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Parsed border radius values from Tailwind |
| 131 | */ |
| 132 | export type TailwindBorderRadius = { |
| 133 | xs: number; |
| 134 | sm: number; |
| 135 | md: number; |
| 136 | lg: number; |
| 137 | xl: number; |
| 138 | "2xl": number; |
| 139 | "3xl": number; |
| 140 | "4xl": number; |
| 141 | }; |
| 142 | |
| 143 | /** |
| 144 | * Extract border radius values from theme.css |
| 145 | */ |
| 146 | export function parseBorderRadius(themeCss: string): TailwindBorderRadius { |
| 147 | const extractRadius = (name: string): number => { |
| 148 | const pattern = new RegExp(`--radius-${name}:\\s*([\\d.]+)rem`); |
| 149 | const match = themeCss.match(pattern); |
| 150 | if (!match) { |
| 151 | throw new Error(`Could not find --radius-${name} in theme.css`); |
| 152 | } |
| 153 | return remToPx(match[1] + "rem"); |
| 154 | }; |
| 155 | |
| 156 | return { |
| 157 | xs: extractRadius("xs"), |
| 158 | sm: extractRadius("sm"), |
| 159 | md: extractRadius("md"), |
| 160 | lg: extractRadius("lg"), |
| 161 | xl: extractRadius("xl"), |
| 162 | "2xl": extractRadius("2xl"), |
| 163 | "3xl": extractRadius("3xl"), |
| 164 | "4xl": extractRadius("4xl"), |
| 165 | }; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Parsed font size values from Tailwind (in pixels) |
| 170 | */ |
| 171 | export type TailwindFontSize = { |
| 172 | xs: number; |
| 173 | sm: number; |
| 174 | base: number; |
| 175 | lg: number; |
| 176 | xl: number; |
| 177 | "2xl": number; |
| 178 | "3xl": number; |
| 179 | "4xl": number; |
| 180 | "5xl": number; |
| 181 | "6xl": number; |
| 182 | "7xl": number; |
| 183 | "8xl": number; |
| 184 | "9xl": number; |
| 185 | }; |
| 186 | |
| 187 | /** |
| 188 | * Extract font size values from theme.css |
| 189 | */ |
| 190 | export function parseFontSize(themeCss: string): TailwindFontSize { |
| 191 | const extractFontSize = (name: string): number => { |
| 192 | const pattern = new RegExp(`--text-${name}:\\s*([\\d.]+)rem`); |
| 193 | const match = themeCss.match(pattern); |
| 194 | if (!match) { |
| 195 | throw new Error(`Could not find --text-${name} in theme.css`); |
| 196 | } |
| 197 | return remToPx(match[1] + "rem"); |
| 198 | }; |
| 199 | |
| 200 | return { |
| 201 | xs: extractFontSize("xs"), |
| 202 | sm: extractFontSize("sm"), |
| 203 | base: extractFontSize("base"), |
| 204 | lg: extractFontSize("lg"), |
| 205 | xl: extractFontSize("xl"), |
| 206 | "2xl": extractFontSize("2xl"), |
| 207 | "3xl": extractFontSize("3xl"), |
| 208 | "4xl": extractFontSize("4xl"), |
| 209 | "5xl": extractFontSize("5xl"), |
| 210 | "6xl": extractFontSize("6xl"), |
| 211 | "7xl": extractFontSize("7xl"), |
| 212 | "8xl": extractFontSize("8xl"), |
| 213 | "9xl": extractFontSize("9xl"), |
| 214 | }; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Parsed font weight values from Tailwind |
| 219 | */ |
| 220 | export type TailwindFontWeight = { |
| 221 | thin: number; |
| 222 | extralight: number; |
| 223 | light: number; |
| 224 | normal: number; |
| 225 | medium: number; |
| 226 | semibold: number; |
| 227 | bold: number; |
| 228 | extrabold: number; |
| 229 | black: number; |
| 230 | }; |
| 231 | |
| 232 | /** |
| 233 | * Extract font weight values from theme.css |
| 234 | */ |
| 235 | export function parseFontWeight(themeCss: string): TailwindFontWeight { |
| 236 | const extractWeight = (name: string): number => { |
| 237 | const pattern = new RegExp(`--font-weight-${name}:\\s*(\\d+)`); |
| 238 | const match = themeCss.match(pattern); |
| 239 | if (!match) { |
| 240 | throw new Error(`Could not find --font-weight-${name} in theme.css`); |
| 241 | } |
| 242 | return parseInt(match[1], 10); |
| 243 | }; |
| 244 | |
| 245 | return { |
| 246 | thin: extractWeight("thin"), |
| 247 | extralight: extractWeight("extralight"), |
| 248 | light: extractWeight("light"), |
| 249 | normal: extractWeight("normal"), |
| 250 | medium: extractWeight("medium"), |
| 251 | semibold: extractWeight("semibold"), |
| 252 | bold: extractWeight("bold"), |
| 253 | extrabold: extractWeight("extrabold"), |
| 254 | black: extractWeight("black"), |
| 255 | }; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Parsed shadow definition |
| 260 | */ |
| 261 | export type ParsedShadow = { |
| 262 | layers: Array<{ |
| 263 | offsetX: number; |
| 264 | offsetY: number; |
| 265 | blur: number; |
| 266 | spread: number; |
| 267 | opacity: number; |
| 268 | }>; |
| 269 | }; |
| 270 | |
| 271 | /** |
| 272 | * Parsed shadow values from Tailwind |
| 273 | */ |
| 274 | export type TailwindShadows = { |
| 275 | "2xs": ParsedShadow; |
| 276 | xs: ParsedShadow; |
| 277 | sm: ParsedShadow; |
| 278 | md: ParsedShadow; |
| 279 | lg: ParsedShadow; |
| 280 | xl: ParsedShadow; |
| 281 | "2xl": ParsedShadow; |
| 282 | }; |
| 283 | |
| 284 | /** |
| 285 | * Parse a CSS shadow string into structured layers |
| 286 | * e.g., "0 1px 2px 0 rgb(0 0 0 / 0.05)" -> { offsetX: 0, offsetY: 1, blur: 2, spread: 0, opacity: 0.05 } |
| 287 | */ |
| 288 | function parseShadowString(shadowStr: string): ParsedShadow { |
| 289 | const layers: ParsedShadow["layers"] = []; |
| 290 | |
| 291 | // Split by comma (for multi-layer shadows), but be careful with rgb() commas |
| 292 | // Shadow layers are separated by ", 0" pattern (comma followed by a shadow starting with 0) |
| 293 | const layerStrings = shadowStr.split(/,\s*(?=\d)/); |
| 294 | |
| 295 | for (const layer of layerStrings) { |
| 296 | // Match pattern: offsetX offsetY blur spread? rgb(0 0 0 / opacity) |
| 297 | // Examples: |
| 298 | // "0 1px 2px 0 rgb(0 0 0 / 0.05)" |
| 299 | // "0 10px 15px -3px rgb(0 0 0 / 0.1)" |
| 300 | const match = layer.match( |
| 301 | /(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?(?:\s+(-?[\d.]+)(?:px)?)?\s+rgb\([^/]+\/\s*([\d.]+)\)/, |
| 302 | ); |
| 303 | |
| 304 | if (match) { |
| 305 | layers.push({ |
| 306 | offsetX: parseFloat(match[1]), |
| 307 | offsetY: parseFloat(match[2]), |
| 308 | blur: parseFloat(match[3]), |
| 309 | spread: match[4] ? parseFloat(match[4]) : 0, |
| 310 | opacity: parseFloat(match[5]), |
| 311 | }); |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | return { layers }; |
| 316 | } |
| 317 | |
| 318 | /** |
| 319 | * Extract shadow values from theme.css |
| 320 | */ |
| 321 | export function parseShadows(themeCss: string): TailwindShadows { |
| 322 | const extractShadow = (name: string): ParsedShadow => { |
| 323 | const pattern = new RegExp(`--shadow-${name}:\\s*([^;]+);`); |
| 324 | const match = themeCss.match(pattern); |
| 325 | if (!match) { |
| 326 | throw new Error(`Could not find --shadow-${name} in theme.css`); |
| 327 | } |
| 328 | return parseShadowString(match[1].trim()); |
| 329 | }; |
| 330 | |
| 331 | return { |
| 332 | "2xs": extractShadow("2xs"), |
| 333 | xs: extractShadow("xs"), |
| 334 | sm: extractShadow("sm"), |
| 335 | md: extractShadow("md"), |
| 336 | lg: extractShadow("lg"), |
| 337 | xl: extractShadow("xl"), |
| 338 | "2xl": extractShadow("2xl"), |
| 339 | }; |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * Complete parsed Tailwind theme |
| 344 | */ |
| 345 | export type TailwindTheme = { |
| 346 | spacing: TailwindSpacing; |
| 347 | borderRadius: TailwindBorderRadius; |
| 348 | fontSize: TailwindFontSize; |
| 349 | fontWeight: TailwindFontWeight; |
| 350 | shadows: TailwindShadows; |
| 351 | }; |
| 352 | |
| 353 | /** |
| 354 | * Parse all theme values from Tailwind's theme.css |
| 355 | */ |
| 356 | export function parseTailwindTheme(): TailwindTheme { |
| 357 | const themeCss = readTailwindThemeCss(); |
| 358 | |
| 359 | return { |
| 360 | spacing: parseSpacing(themeCss), |
| 361 | borderRadius: parseBorderRadius(themeCss), |
| 362 | fontSize: parseFontSize(themeCss), |
| 363 | fontWeight: parseFontWeight(themeCss), |
| 364 | shadows: parseShadows(themeCss), |
| 365 | }; |
| 366 | } |
| 367 | |
| 368 | /** |
| 369 | * Generate the expected SPACING_SCALE object based on Tailwind's base unit |
| 370 | * This can be used to verify the hardcoded values in tailwind-to-figma.ts |
| 371 | */ |
| 372 | export function generateExpectedSpacingScale( |
| 373 | baseUnitPx: number, |
| 374 | ): Record<string, number> { |
| 375 | // Standard Tailwind spacing keys |
| 376 | const keys = [ |
| 377 | "0", |
| 378 | "px", |
| 379 | "0.5", |
| 380 | "1", |
| 381 | "1.5", |
| 382 | "2", |
| 383 | "2.5", |
| 384 | "3", |
| 385 | "3.5", |
| 386 | "4", |
| 387 | "5", |
| 388 | "6", |
| 389 | "6.5", // Kumo custom |
| 390 | "7", |
| 391 | "8", |
| 392 | "9", |
| 393 | "10", |
| 394 | "11", |
| 395 | "12", |
| 396 | "14", |
| 397 | "16", |
| 398 | "20", |
| 399 | "24", |
| 400 | "28", |
| 401 | "32", |
| 402 | "36", |
| 403 | "40", |
| 404 | "44", |
| 405 | "48", |
| 406 | "52", |
| 407 | "56", |
| 408 | "60", |
| 409 | "64", |
| 410 | "72", |
| 411 | "80", |
| 412 | "96", |
| 413 | ]; |
| 414 | |
| 415 | const scale: Record<string, number> = {}; |
| 416 | |
| 417 | for (const key of keys) { |
| 418 | if (key === "0") { |
| 419 | scale[key] = 0; |
| 420 | } else if (key === "px") { |
| 421 | scale[key] = 1; |
| 422 | } else { |
| 423 | scale[key] = getSpacingPx(key, baseUnitPx); |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | return scale; |
| 428 | } |
| 429 | |