#!/usr/bin/env node /** * Script to add 'ui:' prefix to Tailwind classes in packages/ui * Usage: * node add-ui-prefix.cjs # fix all files * node add-ui-prefix.cjs --dry-run # list fixes without writing (exit 0) * node add-ui-prefix.cjs --check # fail if fixes are needed (exit 1) * node add-ui-prefix.cjs --check --staged # check staged packages/ui files only * node add-ui-prefix.cjs --check --changed # check changed packages/ui files vs base ref * node add-ui-prefix.cjs --audit # fail on known prefix corruption patterns * * Env: * UI_PREFIX_GIT_BASE - optional git ref for --changed (default: origin/main) */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const CHECK_MODE = process.argv.includes('--check'); const AUDIT_MODE = process.argv.includes('--audit'); const DRY_RUN = process.argv.includes('--dry-run') || CHECK_MODE; const STAGED_ONLY = process.argv.includes('--staged'); const CHANGED_ONLY = process.argv.includes('--changed'); const UI_PREFIX = 'ui:'; const REPO_ROOT = path.join(__dirname, '../..'); const UI_SRC_DIR = path.join(__dirname, 'src'); const UI_SOURCE_EXTENSIONS = new Set(['.svelte', '.ts', '.tsx', '.js', '.jsx']); // Variant/prop literals — never Tailwind class strings (e.g. side === 'left', variant: 'outline') const SEMANTIC_LITERALS = new Set([ 'left', 'right', 'top', 'bottom', 'center', 'start', 'end', 'outline', 'ghost', 'default', 'destructive', 'secondary', 'link', 'sm', 'md', 'lg', 'xl', '2xl', 'icon', 'dot', 'line', 'page', 'none' ]); // Collapse duplicate ui: markers (e.g. ui:hover:ui:bg-accent → ui:hover:bg-accent) function normalizeUiClass(className) { if (!className.includes(UI_PREFIX)) { return className; } const stripped = className.split(UI_PREFIX).filter(Boolean).join(''); return `${UI_PREFIX}${stripped}`; } function shouldProcessAsClassString(value) { if (!value || value.trim() === '') { return false; } if (SEMANTIC_LITERALS.has(value)) { return false; } const tokens = value.split(/\s+/).filter(Boolean); if (tokens.length === 0) { return false; } if (tokens.some((token) => token.startsWith(UI_PREFIX))) { return true; } const tailwindTokenCount = tokens.filter((token) => isTailwindClass(token)).length; // Avoid treating prose in // comments (e.g. dialog's … sticky footer) as class strings. return tailwindTokenCount >= Math.max(1, Math.ceil(tokens.length * 0.5)); } // Check if a class looks like a Tailwind utility class function isTailwindClass(className) { if (SEMANTIC_LITERALS.has(className)) { return false; } // Skip if already prefixed (including malformed mid-chain ui: after a variant) if (className.startsWith(UI_PREFIX) || className.includes(':ui:')) { return false; } // Skip if it's clearly not a Tailwind class (contains special chars that aren't Tailwind) // Allow brackets for arbitrary values like w-[100px] and colons for variant prefixes like after: if (/[=;{}]/.test(className) && !className.includes('[') && !className.includes(']')) { return false; } // Common Tailwind patterns const patterns = [ // Variant prefixes (must come first to catch all variant combinations) // This catches any class that starts with a variant prefix followed by a colon /^[a-z-]+(\[.*?\])?(\/.*?)?:/, // Layout & Display (including compound classes like inline-flex) /^(flex|grid|block|inline|hidden|absolute|relative|fixed|sticky|contents|list-item|table|table-row|table-cell|inline-flex|inline-grid|inline-block|inline-table)$/, // Flexbox utilities /^(shrink|grow|flex-|basis-)/, // Spacing (m-, p-, gap-, space-, inset-) /^(m|p|mx|my|mt|mr|mb|ml|px|py|pt|pr|pb|pl|gap|space-[xy]|inset)-/, // Negative spacing (e.g., -inset-2, -top-1) /^-\w+/, // Sizing (w-, h-, size-, min-, max-) /^(w|h|min-w|min-h|max-w|max-h|size)-/, // Typography /^(text|font|leading|tracking|text-|font-)/, // Colors /^(bg|text|border|ring|outline|divide|from|via|to|fill|stroke)-/, // Effects /^(opacity|shadow|blur|brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia|backdrop-)/, // Transforms /^(scale|rotate|translate|skew|transform|origin)-/, // Transitions /^(transition|duration|ease|delay|animate)-/, // Interactivity (specific variants) /^(hover|focus|active|disabled|group|peer|aria-|data-):/, // Responsive /^(sm|md|lg|xl|2xl):/, // Dark mode /^dark:/, // Arbitrary values (must come before general patterns) /^[a-z]+-\[/, // Arbitrary variants (like [&_svg]:pointer-events-none, after:absolute, etc.) /^\[.*\]:|^(after|before|first|last|only|nth|marker|placeholder|selection|backdrop):/, // Common utilities /^(rounded|border|outline|shadow|ring|overflow|z-|order-|col-|row-|justify-|items-|content-|self-|place-|object-|cursor-|select-|resize-|appearance-|pointer-events-|visibility-|whitespace-|break-|overflow-|overscroll-|scroll-|will-change-|transform|transition|top-|right-|bottom-|left-)/ ]; return patterns.some((pattern) => pattern.test(className)); } function extractScopedClassNames(content) { const scoped = new Set(); const styleBlocks = content.match(/]*>[\s\S]*?<\/style>/gi); if (!styleBlocks) { return scoped; } for (const block of styleBlocks) { const css = block.replace(/<\/?style[^>]*>/gi, ''); for (const match of css.matchAll(/\.([a-zA-Z_][\w-]*)/g)) { scoped.add(match[1]); } } return scoped; } function isQuotedJsLiteral(token) { return ( (token.startsWith("'") && token.endsWith("'") && token.length > 1) || (token.startsWith('"') && token.endsWith('"') && token.length > 1) ); } function resolveClassToken(cls, scopedClasses = new Set()) { if (isQuotedJsLiteral(cls)) { const quote = cls[0]; const inner = cls.slice(1, -1); const processed = processClassTokens(inner, scopedClasses); return `${quote}${processed}${quote}`; } const bareName = cls.startsWith(UI_PREFIX) ? cls.slice(UI_PREFIX.length) : cls; // Component-scoped hook classes (defined in