cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
5260f1a5703bb69e6c7f7cf0ce8033a561cac8b5

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

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
12import { readFileSync } from "fs";
13import { 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 */
20export 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 */
80export 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 */
88export 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 */
96export 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 */
107export 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 */
124export 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 */
132export 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 */
146export 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 */
171export 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 */
190export 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 */
220export 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 */
235export 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 */
261export 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 */
274export 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 */
288function 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 */
321export 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 */
345export 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 */
356export 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 */
372export 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