import type { DesignToken } from "../token-types.js"; import { inferTokenCategory } from "./categories.js"; import type { ThemeParserResult } from "./types.js"; const CUSTOM_PROPERTY_PATTERN = /^\s*(--[\w-]+)\s*:\s*(.+?)\s*;?\s*$/; export function containsTailwindV4Theme(content: string): boolean { return /^@theme\b/m.test(content); } export function parseTailwindV4Theme( cssContent: string, filePath?: string, ): ThemeParserResult { const startTime = performance.now(); const tokens: DesignToken[] = []; const errors: string[] = []; const warnings: string[] = []; const source = filePath ?? "unknown"; const cleaned = stripBlockComments(cssContent); const lines = cleaned.split("\n"); let insideTheme = false; let themeDepth = 0; let pendingTheme = false; for (let i = 0; i < lines.length; i++) { let line = lines[i]; const lineNumber = i + 1; const singleCommentIdx = line.indexOf("//"); if (singleCommentIdx !== -1) { const beforeComment = line.slice(0, singleCommentIdx); const singleQuotes = (beforeComment.match(/'/g) || []).length; const doubleQuotes = (beforeComment.match(/"/g) || []).length; if (singleQuotes % 2 === 0 && doubleQuotes % 2 === 0) { line = beforeComment; } } const trimmed = line.trim(); if (trimmed === "") continue; if (!insideTheme && !pendingTheme && /^@theme\b/.test(trimmed)) { pendingTheme = true; const braceIdx = trimmed.indexOf("{"); if (braceIdx !== -1) { pendingTheme = false; insideTheme = true; themeDepth = 1; const afterBrace = trimmed.slice(braceIdx + 1); for (const ch of afterBrace) { if (ch === "{") themeDepth++; else if (ch === "}") themeDepth--; } const inlineContent = afterBrace.trim(); if (inlineContent && themeDepth > 0) { processThemeLine(inlineContent, lineNumber, source, tokens, warnings); } if (themeDepth <= 0) { insideTheme = false; } } continue; } if (pendingTheme) { const braceIdx = trimmed.indexOf("{"); if (braceIdx !== -1) { pendingTheme = false; insideTheme = true; themeDepth = 1; const afterBrace = trimmed.slice(braceIdx + 1); for (const ch of afterBrace) { if (ch === "{") themeDepth++; else if (ch === "}") themeDepth--; } const inlineContent = afterBrace.trim(); if (inlineContent && themeDepth > 0) { processThemeLine(inlineContent, lineNumber, source, tokens, warnings); } if (themeDepth <= 0) { insideTheme = false; } } continue; } if (insideTheme) { for (const ch of trimmed) { if (ch === "{") { themeDepth++; } else if (ch === "}") { themeDepth--; if (themeDepth <= 0) { insideTheme = false; break; } } } if (insideTheme || themeDepth > 0) { const contentLine = trimmed.replace(/\}/g, "").trim(); if (contentLine) { processThemeLine(contentLine, lineNumber, source, tokens, warnings); } } } } if (pendingTheme) { warnings.push(`@theme keyword found but no opening brace - incomplete block in ${source}`); } if (insideTheme) { warnings.push(`Unclosed @theme block - missing closing brace in ${source}`); } return { tokens, errors, warnings, parseTimeMs: performance.now() - startTime, }; } function stripBlockComments(css: string): string { return css.replace(/\/\*[\s\S]*?\*\//g, ""); } function processThemeLine( line: string, lineNumber: number, source: string, tokens: DesignToken[], warnings: string[], ): void { const match = line.match(CUSTOM_PROPERTY_PATTERN); if (!match) { const partialMatch = line.match(/^\s*(--[\w-]+)\s*:\s*(.+?)\s*$/); if (partialMatch) { const [, name, rawValue] = partialMatch; tokens.push(buildToken(name, rawValue, lineNumber, source)); return; } if (/^\s*--/.test(line)) { warnings.push( `Line ${lineNumber}: Could not parse custom property declaration: ${line.trim()}`, ); } return; } const [, name, rawValue] = match; tokens.push(buildToken(name, rawValue, lineNumber, source)); } function buildToken( name: string, rawValue: string, lineNumber: number, source: string, ): DesignToken { const value = rawValue.trim(); return { name, rawValue: value, resolvedValue: value, category: inferTokenCategory(name), level: /var\(/.test(value) ? 2 : 1, referenceChain: [], sourceFile: source, lineNumber, theme: "default", selector: "@theme", }; }