Skip to main content
Glama
component-rules.ts77.3 kB
/** * Component Rules Engine * Based on components.build specification by Hayden Bleasel and shadcn * * These rules are enforced, not just documented. * * IMPORTANT: The grading rules are FRAMEWORK-AGNOSTIC. * While examples use React/TypeScript, the core principles apply to: * - React, Vue, Svelte, Angular, Solid, etc. * - The rules check for patterns and best practices, not framework-specific syntax. * * Templates are currently React/TypeScript only. */ export interface Rule { id: string; name: string; description: string; category: 'types' | 'styling' | 'accessibility' | 'composition' | 'state' | 'naming'; severity: 'error' | 'warning' | 'info'; weight: number; check: (code: string) => RuleViolation[]; fix?: (code: string, violation: RuleViolation) => string; example: { bad: string; good: string; }; } export interface RuleViolation { ruleId: string; message: string; line?: number; column?: number; suggestion?: string; } export interface GradeResult { score: number; grade: string; violations: RuleViolation[]; passes: string[]; summary: string; } // Helper to find line number of a match function findLineNumber(code: string, match: string): number | undefined { const index = code.indexOf(match); if (index === -1) return undefined; return code.substring(0, index).split('\n').length; } /** * All component rules from the components.build specification */ export const RULES: Rule[] = [ // ============================================ // TYPES RULES // ============================================ { id: 'extends-html-props', name: 'Extend HTML Props', description: 'Components must extend native HTML attributes using React.ComponentProps', category: 'types', severity: 'error', weight: 15, check: (code) => { const violations: RuleViolation[] = []; // Check if there's a type definition const hasTypeDefinition = /type\s+\w+Props\s*=/.test(code) || /interface\s+\w+Props/.test(code); if (hasTypeDefinition) { // Check if it extends React.ComponentProps or ComponentProps const extendsComponentProps = /React\.ComponentProps<['"`]\w+['"`]>/.test(code) || /ComponentProps<['"`]\w+['"`]>/.test(code); if (!extendsComponentProps) { violations.push({ ruleId: 'extends-html-props', message: 'Component props should extend React.ComponentProps<"element">', suggestion: 'Add React.ComponentProps<"div"> or appropriate element type' }); } } return violations; }, example: { bad: `type ButtonProps = { variant?: 'primary' | 'secondary'; onClick?: () => void; };`, good: `type ButtonProps = React.ComponentProps<'button'> & { variant?: 'primary' | 'secondary'; };` } }, { id: 'exports-types', name: 'Export Types', description: 'Component prop types must be exported', category: 'types', severity: 'error', weight: 10, check: (code) => { const violations: RuleViolation[] = []; // Remove import statements to avoid matching imported types like `type VariantProps` const codeWithoutImports = code.replace(/^import\s+.*$/gm, ''); // Find all Props type definitions (only in actual type/interface declarations, not imports) const propsMatch = codeWithoutImports.match(/(?:type|interface)\s+(\w+Props)/g); if (propsMatch) { for (const match of propsMatch) { const typeName = match.replace(/(?:type|interface)\s+/, ''); // Check if it's exported const isExported = new RegExp(`export\\s+(?:type|interface)\\s+${typeName}`).test(code); if (!isExported) { violations.push({ ruleId: 'exports-types', message: `Type "${typeName}" should be exported`, suggestion: `Add "export" before the type definition` }); } } } return violations; }, example: { bad: `type ButtonProps = React.ComponentProps<'button'>;`, good: `export type ButtonProps = React.ComponentProps<'button'>;` } }, { id: 'props-spread-last', name: 'Props Spread Last', description: 'Spread props (...props) must come last on the element to allow user overrides', category: 'types', severity: 'error', weight: 10, check: (code) => { const violations: RuleViolation[] = []; // Find JSX elements with spread props const jsxWithSpread = code.match(/<\w+[^>]*\{\.\.\.props\}[^>]*>/g); if (jsxWithSpread) { for (const jsx of jsxWithSpread) { // Check if there are props AFTER the spread const afterSpread = jsx.split('{...props}')[1]; if (afterSpread && /\w+=/.test(afterSpread)) { violations.push({ ruleId: 'props-spread-last', message: 'Props are defined after {...props}, which prevents user overrides', line: findLineNumber(code, jsx), suggestion: 'Move {...props} to the end of the element' }); } } } return violations; }, example: { bad: `<div {...props} className="default-class" />`, good: `<div className="default-class" {...props} />` } }, { id: 'single-element-wrap', name: 'Single Element Wrapping', description: 'Each component should wrap a single HTML/JSX element for maximum composability', category: 'composition', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // This is a heuristic - check for multiple root elements in return const returnMatch = code.match(/return\s*\(\s*(<[\s\S]*?>[\s\S]*?<\/[\s\S]*?>)\s*\)/); if (returnMatch) { const jsx = returnMatch[1]; // Count top-level elements (simplified check) const fragmentWrapper = /<>|<React\.Fragment>|<Fragment>/.test(jsx); if (fragmentWrapper) { violations.push({ ruleId: 'single-element-wrap', message: 'Component returns a fragment with multiple elements. Consider breaking into sub-components.', suggestion: 'Use composable sub-components (Root, Header, Content, etc.)' }); } } return violations; }, example: { bad: `return ( <> <div className="header">{title}</div> <div className="content">{content}</div> </> );`, good: `// CardHeader.tsx return <div className="header" {...props} />; // CardContent.tsx return <div className="content" {...props} />;` } }, // ============================================ // STYLING RULES // ============================================ { id: 'uses-cn-utility', name: 'Uses cn() Utility', description: 'Use the cn() utility for class merging (clsx + tailwind-merge)', category: 'styling', severity: 'warning', weight: 8, check: (code) => { const violations: RuleViolation[] = []; // Check if component has className but doesn't use cn() const hasClassName = /className=/.test(code); const usesCn = /cn\(/.test(code); const hasConditionalClasses = /className=\{.*\?.*:/.test(code) || /className=\{`/.test(code); if (hasClassName && hasConditionalClasses && !usesCn) { violations.push({ ruleId: 'uses-cn-utility', message: 'Use cn() utility for conditional class names instead of template literals or ternaries', suggestion: 'Import cn from @/lib/utils and use cn(baseClasses, conditionalClasses, className)' }); } return violations; }, example: { bad: `className={\`base-class \${isActive ? 'active' : ''}\`}`, good: `className={cn('base-class', isActive && 'active', className)}` } }, { id: 'class-order', name: 'Class Order', description: 'Classes should be ordered: base, variants, conditionals, user overrides (className last)', category: 'styling', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Match cn() calls, handling nested parentheses for variant functions const cnCallRegex = /cn\(([^)]*(?:\([^)]*\)[^)]*)*)\)/g; let match; while ((match = cnCallRegex.exec(code)) !== null) { const args = match[1]; // Check 1: className should be last // className followed by a comma AND then another argument means it's NOT last // But trailing commas are OK: cn("base", className,) - className IS last here // We check: is there actual content (not just whitespace) after "className,"? const classNameNotLast = /className\s*,\s*(?!\s*$)(?!\s*\))[\w'"&|{]/.test(args); if (classNameNotLast) { violations.push({ ruleId: 'class-order', message: 'className should be the last argument in cn() to allow user overrides', line: findLineNumber(code, match[0]), suggestion: 'Move className to the end: cn(baseClasses, variants, conditionals, className)' }); } // Check 2: Base styles (string literals) should come first // If we see a conditional (&&) or variable before a string literal, flag it const firstArg = args.split(',')[0]?.trim(); if (firstArg && /^[a-zA-Z]/.test(firstArg) && !firstArg.startsWith("'") && !firstArg.startsWith('"')) { // First arg is a variable/conditional, not a string literal // Check if there are string literals later const hasLaterStringLiteral = /,\s*['"][^'"]+['"]/.test(args); if (hasLaterStringLiteral) { violations.push({ ruleId: 'class-order', message: 'Base styles (string literals) should come before variables and conditionals', line: findLineNumber(code, match[0]), suggestion: 'Order: cn("base-styles", variantStyles, conditional && "active", className)' }); } } // Check 3: CVA variants function should come before conditionals // Look for pattern: conditional && before variantFunction( if (/\w+\s*&&\s*['"][^'"]*['"].*\w+Variants?\(/.test(args)) { violations.push({ ruleId: 'class-order', message: 'Variant styles should come before conditional styles', line: findLineNumber(code, match[0]), suggestion: 'Order: cn("base", variants({ size }), isActive && "active", className)' }); } } return violations; }, example: { bad: `cn(className, 'base-styles', isActive && 'active') cn(isActive && 'active', 'base-styles')`, good: `cn( 'base-styles', // 1. Base (always applied) variants({ size }), // 2. Variants (based on props) isActive && 'active', // 3. Conditionals (based on state) className // 4. User overrides (last!) )` } }, { id: 'uses-design-tokens', name: 'Uses Design Tokens', description: 'Use CSS variables/design tokens instead of hardcoded colors', category: 'styling', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for Tailwind arbitrary hex values: bg-[#5B9BD5], text-[#2B5BA8], from-[#hex], etc. const arbitraryHexColors = code.match(/(?:bg|text|border|fill|stroke|from|to|via|ring|outline|shadow|accent|caret|decoration)-\[#[0-9A-Fa-f]{3,8}(?:\/\d+)?\]/g); if (arbitraryHexColors && arbitraryHexColors.length > 0) { violations.push({ ruleId: 'uses-design-tokens', message: `Found ${arbitraryHexColors.length} hardcoded hex color(s) in Tailwind arbitrary values: ${arbitraryHexColors.slice(0, 3).join(', ')}`, suggestion: `Use CSS variables instead: bg-[var(--custom-color)] or define semantic tokens. Bad: bg-[#5B9BD5] text-[#2B5BA8] dark:bg-[#5B9BD5]/10 Good: bg-primary text-primary-foreground (with CSS vars handling dark mode)`, line: findLineNumber(code, arbitraryHexColors[0]) }); } // Check for manual dark mode color overrides (sign of missing design tokens) const darkModeColorOverrides = code.match(/dark:(?:bg|text|border|fill|stroke|from|to|via|ring|outline|shadow|accent|caret|decoration)-\[#[0-9A-Fa-f]{3,8}/g); if (darkModeColorOverrides && darkModeColorOverrides.length > 0) { violations.push({ ruleId: 'uses-design-tokens', message: `Found ${darkModeColorOverrides.length} manual dark mode color overrides - this should be handled by CSS variables`, suggestion: `Instead of: bg-[#5B9BD5] dark:bg-[#7BA3D9] Use: bg-[var(--brand-color)] where --brand-color changes in .dark` }); } // Check for hardcoded color values in className (Tailwind palette colors) const hardcodedTailwindColors = code.match(/(?:bg|text|border)-(?:red|blue|green|yellow|purple|pink|gray|slate|zinc|neutral|stone|amber|lime|emerald|teal|cyan|sky|indigo|violet|fuchsia|rose)-\d{2,3}/g); if (hardcodedTailwindColors && hardcodedTailwindColors.length > 3) { violations.push({ ruleId: 'uses-design-tokens', message: 'Consider using semantic design tokens (primary, secondary, destructive) instead of hardcoded Tailwind colors', suggestion: 'Use bg-primary, text-foreground, border-border, etc.' }); } // Check for hardcoded hex colors in style objects or JSX // Matches: color: "#8B5CF6", background: '#fff', etc. const hexInStyles = code.match(/(?:color|background|backgroundColor|borderColor|fill|stroke)\s*:\s*["']#[0-9A-Fa-f]{3,8}["']/g); if (hexInStyles && hexInStyles.length > 0) { violations.push({ ruleId: 'uses-design-tokens', message: `Found ${hexInStyles.length} hardcoded hex color(s) in style objects`, suggestion: 'Use CSS variables: style={{ color: "var(--primary)" }} or define as CSS custom properties', line: findLineNumber(code, hexInStyles[0]) }); } // Check for hex values assigned to variables (config objects) // Matches: primary: "#8B5CF6", color: '#fff', etc. const hexInConfig = code.match(/\w+\s*:\s*["']#[0-9A-Fa-f]{3,8}["']/g); // Filter out CSS variable definitions which are fine const nonCssVarHex = hexInConfig?.filter(match => !match.includes('--')); if (nonCssVarHex && nonCssVarHex.length > 3) { violations.push({ ruleId: 'uses-design-tokens', message: `Found ${nonCssVarHex.length} hardcoded hex colors in config/variables`, suggestion: 'Define colors as CSS variables: "--palette-primary": "#8B5CF6" then use var(--palette-primary)' }); } // Check for rgb/rgba/hsl/hsla hardcoded values const hardcodedRgbHsl = code.match(/(?:rgb|rgba|hsl|hsla)\s*\([^)]+\)/g); if (hardcodedRgbHsl && hardcodedRgbHsl.length > 3) { violations.push({ ruleId: 'uses-design-tokens', message: `Found ${hardcodedRgbHsl.length} hardcoded rgb/hsl colors`, suggestion: 'Use CSS variables for colors to enable theming' }); } return violations; }, example: { bad: `<Badge className="bg-[#5B9BD5]/20 text-[#2B5BA8] dark:bg-[#5B9BD5]/10 dark:text-[#7BA3D9]"> const config = { primary: "#8B5CF6" };`, good: `const config = { "--primary": "#8B5CF6" }; <div style={config as React.CSSProperties} className="text-[var(--primary)]">` } }, { id: 'uses-semantic-tokens', name: 'Uses Semantic Design Tokens', description: 'Use semantic token names (background, foreground, primary) instead of color names', category: 'styling', severity: 'warning', weight: 8, check: (code) => { const violations: RuleViolation[] = []; // Semantic tokens that SHOULD be used const semanticTokens = [ 'background', 'foreground', 'primary', 'primary-foreground', 'secondary', 'secondary-foreground', 'muted', 'muted-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground', 'border', 'input', 'ring', 'card', 'card-foreground', 'popover', 'popover-foreground' ]; // Check if component uses Tailwind classes const hasTailwindClasses = /className=.*(?:bg-|text-|border-)/.test(code); if (hasTailwindClasses) { // Check for non-semantic color usage (e.g., bg-white, text-black, bg-gray-100) const nonSemanticColors = code.match(/(?:bg|text|border)-(?:white|black|transparent)/g) || []; const paletteColors = code.match(/(?:bg|text|border)-(?:gray|slate|zinc|neutral|stone)-\d{2,3}/g) || []; // Check if semantic tokens are used const usesSemanticTokens = semanticTokens.some(token => new RegExp(`(?:bg|text|border)-${token}(?:-foreground)?`).test(code) ); // If using lots of non-semantic colors and no semantic tokens, flag it if ((nonSemanticColors.length + paletteColors.length) > 3 && !usesSemanticTokens) { violations.push({ ruleId: 'uses-semantic-tokens', message: 'Use semantic design tokens instead of color names for better theming support', suggestion: `Replace bg-white → bg-background, text-black → text-foreground, bg-gray-100 → bg-muted, etc. Semantic tokens to use: - background/foreground: Page background and main text - primary/primary-foreground: Brand color and its text - secondary/secondary-foreground: Secondary actions - muted/muted-foreground: Subtle backgrounds and text - accent/accent-foreground: Highlights - destructive/destructive-foreground: Errors/warnings - border, input, ring: Borders and focus states - card/card-foreground: Card surfaces` }); } // Check for direct color values that should use tokens const directWhiteBlack = code.match(/(?:bg|text|border)-(?:white|black)/g); if (directWhiteBlack && directWhiteBlack.length > 0) { violations.push({ ruleId: 'uses-semantic-tokens', message: `Found ${directWhiteBlack.length} uses of bg-white/black - these break dark mode`, suggestion: 'bg-white → bg-background or bg-card, text-black → text-foreground' }); } } // Check for CSS variable usage without semantic naming const cssVarUsage = code.match(/var\(--[\w-]+\)/g); if (cssVarUsage) { const nonSemanticVars = cssVarUsage.filter(v => { const varName = v.match(/--[\w-]+/)?.[0] || ''; // Check if it's a color-based name instead of semantic return /--(?:red|blue|green|yellow|purple|pink|gray|white|black|color-\d)/.test(varName); }); if (nonSemanticVars.length > 0) { violations.push({ ruleId: 'uses-semantic-tokens', message: `Found CSS variables with color names instead of semantic names: ${nonSemanticVars.slice(0, 3).join(', ')}`, suggestion: 'Use semantic names: --primary instead of --blue, --destructive instead of --red' }); } } return violations; }, example: { bad: `// Hardcoded colors break dark mode <div className="bg-white text-black border-gray-200"> <button className="bg-blue-500 text-white">Click</button> </div>`, good: `// Semantic tokens adapt to theme <div className="bg-background text-foreground border-border"> <button className="bg-primary text-primary-foreground">Click</button> </div> /* In globals.css */ :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); }` } }, // ============================================ // DATA ATTRIBUTES RULES // ============================================ { id: 'has-data-slot', name: 'Has data-slot Attribute', description: 'Components should have a data-slot attribute for parent-aware styling', category: 'styling', severity: 'warning', weight: 8, check: (code) => { const violations: RuleViolation[] = []; // Check if this looks like a component (has export and returns JSX) const isComponent = /export\s+(?:const|function)/.test(code) && /<\w+/.test(code); const hasDataSlot = /data-slot=/.test(code); // Skip blocks - they are compositions, not components // Blocks use components that have data-slot, but the block itself doesn't need one const isBlock = /(?:Hero|Pricing|Features|Footer|Header|Auth|Onboarding|Dashboard|Settings|Billing|Chat|Landing|Block|Page|Section)(?:Block|Page|Section)?/i.test(code); if (isComponent && !hasDataSlot && !isBlock) { violations.push({ ruleId: 'has-data-slot', message: 'Component should have a data-slot attribute for identification', suggestion: 'Add data-slot="component-name" to the root element' }); } return violations; }, example: { bad: `<div className="card" {...props} />`, good: `<div data-slot="card" className="card" {...props} />` } }, { id: 'uses-data-state', name: 'Uses data-state for Visual States', description: 'Use data-state attribute to expose component state for styling', category: 'styling', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check if component has state that should be exposed const hasOpenState = /isOpen|open|setOpen/.test(code); const hasActiveState = /isActive|active|setActive/.test(code); const hasDataState = /data-state=/.test(code); if ((hasOpenState || hasActiveState) && !hasDataState) { violations.push({ ruleId: 'uses-data-state', message: 'Component has state that could be exposed via data-state attribute', suggestion: 'Add data-state={isOpen ? "open" : "closed"} for styling hooks' }); } return violations; }, example: { bad: `<div className={isOpen ? 'opacity-100' : 'opacity-0'} />`, good: `<div data-state={isOpen ? 'open' : 'closed'} className="data-[state=open]:opacity-100 data-[state=closed]:opacity-0" />` } }, // ============================================ // ACCESSIBILITY RULES // ============================================ { id: 'button-has-type', name: 'Button Has Type', description: 'Button elements must have an explicit type attribute', category: 'accessibility', severity: 'error', weight: 10, check: (code) => { const violations: RuleViolation[] = []; // Find button elements without type const buttonMatches = code.match(/<button[^>]*>/g); if (buttonMatches) { for (const match of buttonMatches) { if (!/type=/.test(match)) { violations.push({ ruleId: 'button-has-type', message: 'Button element missing type attribute', line: findLineNumber(code, match), suggestion: 'Add type="button" or type="submit"' }); } } } return violations; }, example: { bad: `<button onClick={handleClick}>Click me</button>`, good: `<button type="button" onClick={handleClick}>Click me</button>` } }, { id: 'interactive-has-keyboard', name: 'Interactive Elements Have Keyboard Support', description: 'Interactive non-button elements must have keyboard event handlers', category: 'accessibility', severity: 'error', weight: 12, check: (code) => { const violations: RuleViolation[] = []; // Find div/span with onClick but no onKeyDown/onKeyUp const interactiveMatches = code.match(/<(?:div|span)[^>]*onClick[^>]*>/g); if (interactiveMatches) { for (const match of interactiveMatches) { const hasKeyHandler = /onKey(?:Down|Up|Press)/.test(match); const hasRole = /role=/.test(match); if (!hasKeyHandler) { violations.push({ ruleId: 'interactive-has-keyboard', message: 'Interactive element with onClick needs keyboard handler', line: findLineNumber(code, match), suggestion: 'Add onKeyDown handler for Enter/Space activation, or use a <button>' }); } if (!hasRole) { violations.push({ ruleId: 'interactive-has-keyboard', message: 'Interactive element needs role="button"', line: findLineNumber(code, match), suggestion: 'Add role="button" and tabIndex={0}' }); } } } return violations; }, example: { bad: `<div onClick={handleClick}>Click me</div>`, good: `<div role="button" tabIndex={0} onClick={handleClick} onKeyDown={(e) => e.key === 'Enter' && handleClick()} >Click me</div>` } }, { id: 'icon-button-has-label', name: 'Icon Buttons Have Labels', description: 'Icon-only buttons must have aria-label or visually hidden text', category: 'accessibility', severity: 'error', weight: 12, check: (code) => { const violations: RuleViolation[] = []; // Check for buttons that only contain icons (simplified heuristic) const iconButtonPattern = /<button[^>]*>\s*<(?:svg|Icon|\w+Icon)[^>]*\/>\s*<\/button>/g; const matches = code.match(iconButtonPattern); if (matches) { for (const match of matches) { const hasAriaLabel = /aria-label=/.test(match); const hasSrOnly = /sr-only|visually-hidden/.test(match); if (!hasAriaLabel && !hasSrOnly) { violations.push({ ruleId: 'icon-button-has-label', message: 'Icon-only button needs aria-label for screen readers', line: findLineNumber(code, match), suggestion: 'Add aria-label="Description" to the button' }); } } } return violations; }, example: { bad: `<button><TrashIcon /></button>`, good: `<button aria-label="Delete item"><TrashIcon aria-hidden="true" /></button>` } }, { id: 'uses-semantic-html', name: 'Uses Semantic HTML', description: 'Use semantic HTML elements instead of divs with roles', category: 'accessibility', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for div with button role (should be button) if (/<div[^>]*role=["']button["']/.test(code)) { violations.push({ ruleId: 'uses-semantic-html', message: 'Use <button> instead of <div role="button">', suggestion: 'Replace with semantic <button> element' }); } // Check for div with nav role (should be nav) if (/<div[^>]*role=["']navigation["']/.test(code)) { violations.push({ ruleId: 'uses-semantic-html', message: 'Use <nav> instead of <div role="navigation">', suggestion: 'Replace with semantic <nav> element' }); } return violations; }, example: { bad: `<div role="button" onClick={handleClick}>Click</div>`, good: `<button onClick={handleClick}>Click</button>` } }, { id: 'aria-expanded-with-controls', name: 'aria-expanded Has aria-controls', description: 'Elements with aria-expanded should have aria-controls pointing to the controlled element', category: 'accessibility', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Find elements with aria-expanded const expandedMatches = code.match(/<\w+[^>]*aria-expanded[^>]*>/g); if (expandedMatches) { for (const match of expandedMatches) { if (!/aria-controls=/.test(match)) { violations.push({ ruleId: 'aria-expanded-with-controls', message: 'Element with aria-expanded should have aria-controls', line: findLineNumber(code, match), suggestion: 'Add aria-controls="content-id" pointing to the expandable content' }); } } } return violations; }, example: { bad: `<button aria-expanded={isOpen}>Toggle</button>`, good: `<button aria-expanded={isOpen} aria-controls="panel-1">Toggle</button> <div id="panel-1">{content}</div>` } }, // ============================================ // STATE RULES // ============================================ { id: 'supports-controlled-uncontrolled', name: 'Supports Controlled and Uncontrolled', description: 'Stateful components should support both controlled and uncontrolled usage', category: 'state', severity: 'info', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check if component has useState but no controlled props const hasUseState = /useState/.test(code); const hasValueProp = /value.*:/.test(code) || /\bvalue\b/.test(code); const hasDefaultValueProp = /defaultValue/.test(code); const hasOnChangeProp = /onValueChange|onChange/.test(code); if (hasUseState && !hasDefaultValueProp && !hasOnChangeProp) { violations.push({ ruleId: 'supports-controlled-uncontrolled', message: 'Stateful component should support controlled usage', suggestion: 'Add value, defaultValue, and onValueChange props. Consider using useControllableState.' }); } return violations; }, example: { bad: `const Stepper = () => { const [value, setValue] = useState(0); return <div>{value}</div>; };`, good: `const Stepper = ({ value: controlledValue, defaultValue, onValueChange }) => { const [value, setValue] = useControllableState({ prop: controlledValue, defaultProp: defaultValue, onChange: onValueChange, }); return <div>{value}</div>; };` } }, // ============================================ // NAMING RULES // ============================================ { id: 'composable-naming', name: 'Composable Naming Convention', description: 'Sub-components should follow naming conventions: Root, Trigger, Content, Header, Footer, Title, Description', category: 'naming', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check if file exports multiple components (compound component pattern) const exports = code.match(/export\s+(?:const|function)\s+(\w+)/g); if (exports && exports.length > 1) { const validNames = ['Root', 'Item', 'Trigger', 'Content', 'Header', 'Body', 'Footer', 'Title', 'Description', 'Close', 'Overlay', 'Portal']; for (const exp of exports) { const name = exp.replace(/export\s+(?:const|function)\s+/, ''); const hasValidSuffix = validNames.some(valid => name.endsWith(valid) || name === valid); if (!hasValidSuffix && name !== 'default') { // This is informational, not an error } } } return violations; }, example: { bad: `export const AccordionContainer = ... export const AccordionButton = ... export const AccordionPanel = ...`, good: `export const Root = ... export const Trigger = ... export const Content = ...` } }, { id: 'data-slot-naming', name: 'data-slot Naming Convention', description: 'data-slot values should use kebab-case and be descriptive', category: 'naming', severity: 'info', weight: 2, check: (code) => { const violations: RuleViolation[] = []; // Find data-slot values const dataSlotMatches = code.match(/data-slot=["']([^"']+)["']/g); if (dataSlotMatches) { for (const match of dataSlotMatches) { const value = match.match(/data-slot=["']([^"']+)["']/)?.[1]; if (value) { // Check for camelCase (should be kebab-case) if (/[a-z][A-Z]/.test(value)) { violations.push({ ruleId: 'data-slot-naming', message: `data-slot value "${value}" should use kebab-case`, suggestion: `Change to "${value.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}"` }); } } } } return violations; }, example: { bad: `data-slot="cardHeader"`, good: `data-slot="card-header"` } }, // ============================================ // POLYMORPHISM RULES // ============================================ { id: 'supports-polymorphism', name: 'Supports Polymorphism', description: 'Interactive and layout components should support polymorphism via as or asChild prop', category: 'composition', severity: 'warning', weight: 8, check: (code) => { const violations: RuleViolation[] = []; // Check if this is an interactive component (Button, Link, etc.) const isInteractiveComponent = /export\s+(?:const|function)\s+(?:Button|Link|Clickable|Interactive|Action|Trigger)/i.test(code); // Check if this is a layout/container component const isLayoutComponent = /export\s+(?:const|function)\s+(?:Box|Container|Flex|Grid|Stack|Card|Section|Article|Main|Header|Footer|Aside|Nav)/i.test(code); // Check if component uses Slot from Radix const usesSlot = /import.*Slot.*from\s+['"]@radix-ui\/react-slot['"]/.test(code); const hasAsChild = /asChild/.test(code); // Check if component accepts 'as' prop const hasAsProp = /\bas\s*[?:]/.test(code) || /as\s*=/.test(code) || /as:\s*Element/.test(code); // Check for compound component sub-parts that render links/buttons without polymorphism // Matches: const NavItem = ..., const Logo = ..., function MobileNavItem, etc. const subComponentPatterns = [ // Navigation items - should support button OR link { pattern: /(?:const|function)\s+(NavItem|NavigationItem|MenuItem|MobileNavItem|NavLink)/g, type: 'navigation' }, // Logo - might be link or static { pattern: /(?:const|function)\s+(Logo|Brand|LogoLink)/g, type: 'logo' }, // Root/Container components - should support semantic elements { pattern: /(?:const|function)\s+(Root|Wrapper|Container|Section|Layout)/g, type: 'container' }, // Action items { pattern: /(?:const|function)\s+(Action|CTA|ActionButton)/g, type: 'action' }, ]; for (const { pattern, type } of subComponentPatterns) { const matches = code.match(pattern); if (matches && !hasAsChild && !hasAsProp && !usesSlot) { // Check if the component renders a hardcoded <a> or <button> const rendersHardcodedLink = /<a\s/.test(code) && !/<a\s[^>]*\{/.test(code); const rendersHardcodedButton = /<button\s/.test(code) && !/<button\s[^>]*\{/.test(code); if ((type === 'navigation' || type === 'logo' || type === 'action') && (rendersHardcodedLink || rendersHardcodedButton)) { violations.push({ ruleId: 'supports-polymorphism', message: `${matches[0].replace(/(?:const|function)\s+/, '')} renders hardcoded ${rendersHardcodedLink ? '<a>' : '<button>'} - add asChild support for Next.js Link / React Router compatibility`, suggestion: `Add asChild prop for router integration: // Before (hardcoded anchor) const NavItem = ({ href, children }) => ( <a href={href}>{children}</a> ) // After (supports any link component) import { Slot } from "@radix-ui/react-slot" const NavItem = ({ href, asChild = false, children, ...props }) => { const Comp = asChild ? Slot : "a" return <Comp href={!asChild ? href : undefined} {...props}>{children}</Comp> } // Usage with Next.js: <NavItem asChild> <Link href="/features">Features</Link> </NavItem>` }); } if (type === 'container') { violations.push({ ruleId: 'supports-polymorphism', message: `${matches[0].replace(/(?:const|function)\s+/, '')} should support 'as' prop for semantic HTML (<section>, <article>, etc.)`, suggestion: `Add 'as' prop: const Root = <E extends React.ElementType = 'div'>({ as, ...props }: { as?: E } & React.ComponentPropsWithoutRef<E>) => { const Element = as || 'div'; return <Element {...props} />; } // Usage: <Hero.Root as="section" aria-labelledby="hero-title"> <Hero.Root as="article">` }); } } } // Interactive components SHOULD support polymorphism if (isInteractiveComponent && !usesSlot && !hasAsChild && !hasAsProp) { violations.push({ ruleId: 'supports-polymorphism', message: 'Interactive component should support polymorphism (as or asChild prop)', suggestion: `Use Radix Slot for asChild pattern: import { Slot } from "@radix-ui/react-slot" function Button({ asChild = false, ...props }) { const Comp = asChild ? Slot : "button" return <Comp {...props} /> } // Usage: <Button asChild><a href="/">Link Button</a></Button>` }); } // Layout components SHOULD support 'as' prop for semantic HTML if (isLayoutComponent && !hasAsProp && !hasAsChild) { violations.push({ ruleId: 'supports-polymorphism', message: 'Layout component should support the "as" prop for semantic HTML', suggestion: `Add 'as' prop for semantic flexibility: type BoxProps<E extends React.ElementType = 'div'> = { as?: E; } & React.ComponentPropsWithoutRef<E>; function Box<E extends React.ElementType = 'div'>({ as, ...props }: BoxProps<E>) { const Element = as || 'div'; return <Element {...props} />; } // Usage: <Box as="section">Content</Box> <Box as="nav">Navigation</Box> <Box as="article">Article</Box>` }); } // Check for hardcoded elements that could be polymorphic // e.g., a component that always renders <div> but could be semantic const rendersDiv = /<div[^>]*data-slot=/.test(code); const hasSemanticProps = /role=["'](?:navigation|main|banner|contentinfo|complementary|article|region)["']/.test(code); if (rendersDiv && hasSemanticProps && !hasAsProp) { violations.push({ ruleId: 'supports-polymorphism', message: 'Component uses role attribute - consider supporting "as" prop for semantic HTML instead', suggestion: 'Replace <div role="navigation"> with as="nav" support' }); } return violations; }, example: { bad: `// Hardcoded element - no flexibility function Button({ children, ...props }) { return <button {...props}>{children}</button> } // Hardcoded div with role - should use semantic element function Nav({ children }) { return <div role="navigation">{children}</div> }`, good: `// With asChild (Radix Slot pattern) import { Slot } from "@radix-ui/react-slot" function Button({ asChild = false, ...props }) { const Comp = asChild ? Slot : "button" return <Comp data-slot="button" {...props} /> } // With as prop (for layout components) function Container<E extends React.ElementType = 'div'>({ as, ...props }: { as?: E } & React.ComponentPropsWithoutRef<E>) { const Element = as || 'div'; return <Element data-slot="container" {...props} />; } // Usage: <Button asChild><a href="/">Link</a></Button> <Container as="section">Content</Container> <Container as="nav">Navigation</Container>` } }, { id: 'semantic-default-element', name: 'Semantic Default Element', description: 'Polymorphic components should default to semantic elements, not generic divs', category: 'composition', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for components with 'as' prop that default to 'div' when they should be semantic const componentDefaults = [ // Component name patterns and their expected semantic defaults { pattern: /(?:const|function)\s+Article.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'article' }, { pattern: /(?:const|function)\s+Navigation.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'nav' }, { pattern: /(?:const|function)\s+Nav\b.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'nav' }, { pattern: /(?:const|function)\s+Header\b.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'header' }, { pattern: /(?:const|function)\s+Footer\b.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'footer' }, { pattern: /(?:const|function)\s+Main\b.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'main' }, { pattern: /(?:const|function)\s+Aside\b.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'aside' }, { pattern: /(?:const|function)\s+Section\b.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'section' }, { pattern: /(?:const|function)\s+Heading.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'h2' }, { pattern: /(?:const|function)\s+Title.*as:\s*Element\s*=\s*['"]div['"]/, expected: 'h1 or h2' }, ]; for (const { pattern, expected } of componentDefaults) { if (pattern.test(code)) { const match = code.match(/(?:const|function)\s+(\w+)/); const componentName = match ? match[1] : 'Component'; violations.push({ ruleId: 'semantic-default-element', message: `${componentName} defaults to 'div' but should default to '${expected}'`, suggestion: `Change default from 'div' to '${expected}': // ❌ Too generic function ${componentName}({ as: Element = 'div', ...props }) { } // ✅ Semantic default function ${componentName}({ as: Element = '${expected}', ...props }) { }` }); } } return violations; }, example: { bad: `// Too generic defaults function Article({ as: Element = 'div', ...props }) { } function Navigation({ as: Element = 'div', ...props }) { } function Heading({ as: Element = 'div', ...props }) { }`, good: `// Semantic defaults function Article({ as: Element = 'article', ...props }) { } function Navigation({ as: Element = 'nav', ...props }) { } function Heading({ as: Element = 'h2', ...props }) { }` } }, { id: 'polymorphic-type-safety', name: 'Polymorphic Type Safety', description: 'Polymorphic components should use proper TypeScript generics for type safety', category: 'types', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for 'as' prop with 'any' type if (/as\s*\??\s*:\s*any/.test(code)) { violations.push({ ruleId: 'polymorphic-type-safety', message: "The 'as' prop is typed as 'any' - this loses type safety", suggestion: `Use proper generic typing: type PolymorphicProps<E extends React.ElementType> = { as?: E; } & React.ComponentPropsWithoutRef<E>; function Component<E extends React.ElementType = 'div'>({ as, ...props }: PolymorphicProps<E>) { const Element = as || 'div'; return <Element {...props} />; }` }); } // Check for 'as' prop with string type instead of ElementType if (/as\s*\??\s*:\s*string/.test(code)) { violations.push({ ruleId: 'polymorphic-type-safety', message: "The 'as' prop is typed as 'string' - use React.ElementType for proper inference", suggestion: `Use React.ElementType: // ❌ No inference as?: string // ✅ Full type inference <E extends React.ElementType = 'div'> as?: E` }); } // Check for polymorphic component without generic type parameter const hasAsProp = /\bas\s*[?:]/.test(code); const hasGeneric = /<\s*E\s+extends\s+React\.ElementType/.test(code); if (hasAsProp && !hasGeneric && !/asChild/.test(code)) { // Only flag if it looks like a polymorphic component definition if (/(?:const|function)\s+\w+.*as\s*[?:]/.test(code)) { violations.push({ ruleId: 'polymorphic-type-safety', message: "Polymorphic component lacks generic type parameter - props won't be inferred correctly", suggestion: `Add generic type parameter: function Component<E extends React.ElementType = 'div'>({ as, ...props }: { as?: E } & React.ComponentPropsWithoutRef<E>) { const Element = as || 'div'; return <Element {...props} />; } // Now TypeScript infers props based on 'as': <Component as="a" href="/" /> // ✅ href is valid <Component as="button" href="/" /> // ❌ Error: href not on button` }); } } return violations; }, example: { bad: `// No type safety function Component({ as: Element = 'div', ...props }: any) { return <Element {...props} />; } // String type loses inference function Box({ as }: { as?: string }) { }`, good: `// Full type safety with generics type PolymorphicProps<E extends React.ElementType> = { as?: E; } & React.ComponentPropsWithoutRef<E>; function Component<E extends React.ElementType = 'div'>({ as, ...props }: PolymorphicProps<E>) { const Element = as || 'div'; return <Element {...props} />; } // Props are inferred from element type: <Component as="a" href="/home" /> // ✅ href valid <Component as="button" type="submit" /> // ✅ type valid` } }, { id: 'polymorphic-keyboard-handler', name: 'Polymorphic Keyboard Handler', description: 'Non-button interactive elements need keyboard event handlers for accessibility', category: 'accessibility', severity: 'warning', weight: 8, check: (code) => { const violations: RuleViolation[] = []; // Check for onClick without onKeyDown on polymorphic components const hasAsProp = /\bas\s*[?:]/.test(code) || /as\s*=/.test(code); const hasOnClick = /onClick/.test(code); const hasKeyboardHandler = /onKeyDown|onKeyUp|onKeyPress/.test(code); // If component supports 'as' and has onClick but no keyboard handler if (hasAsProp && hasOnClick && !hasKeyboardHandler) { violations.push({ ruleId: 'polymorphic-keyboard-handler', message: "Polymorphic component with onClick needs keyboard handler for when rendered as non-button", suggestion: `Add keyboard support for accessibility: function Interactive({ as: Element = 'button', onClick, ...props }) { const handleKeyDown = (e: React.KeyboardEvent) => { // Only needed for non-button elements if (Element !== 'button' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick?.(e as any); } }; return ( <Element onClick={onClick} onKeyDown={Element !== 'button' ? handleKeyDown : undefined} tabIndex={Element !== 'button' && Element !== 'a' ? 0 : undefined} role={Element !== 'button' && Element !== 'a' ? 'button' : undefined} {...props} /> ); }` }); } return violations; }, example: { bad: `// Missing keyboard support function Clickable({ as: Element = 'div', onClick, ...props }) { return <Element onClick={onClick} {...props} />; } // When rendered as div, Enter/Space won't work!`, good: `// With keyboard support function Clickable({ as: Element = 'button', onClick, ...props }) { const handleKeyDown = (e: React.KeyboardEvent) => { if (Element !== 'button' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick?.(e as any); } }; return ( <Element onClick={onClick} onKeyDown={Element !== 'button' ? handleKeyDown : undefined} tabIndex={Element !== 'button' ? 0 : undefined} role={Element !== 'button' ? 'button' : undefined} {...props} /> ); }` } }, { id: 'as-prop-documented', name: 'As Prop Documented', description: 'The as prop should have JSDoc documenting valid element choices', category: 'types', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check for 'as' prop without JSDoc const hasAsProp = /\bas\s*\??\s*:/.test(code); const hasAsJsDoc = /\/\*\*[\s\S]*?@(?:example|default)[\s\S]*?\*\/[\s\n]*.*as\s*\??\s*:/.test(code); if (hasAsProp && !hasAsJsDoc) { violations.push({ ruleId: 'as-prop-documented', message: "The 'as' prop should have JSDoc documenting valid elements", suggestion: `Add JSDoc to 'as' prop: interface BoxProps { /** * The HTML element to render as * @default 'div' * @example 'section', 'article', 'aside', 'main' */ as?: 'div' | 'section' | 'article' | 'aside' | 'main' | 'header' | 'footer'; }` }); } return violations; }, example: { bad: `interface BoxProps { as?: React.ElementType; // No documentation }`, good: `interface BoxProps { /** * The HTML element to render as * @default 'div' * @example 'section', 'article', 'aside', 'main' */ as?: 'div' | 'section' | 'article' | 'aside' | 'main' | 'header' | 'footer'; }` } }, // ============================================ // DOCUMENTATION RULES // ============================================ { id: 'variants-documented', name: 'Variants Are Documented', description: 'Variant props should have JSDoc comments explaining their purpose', category: 'types', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check for variant/size props without JSDoc const variantProps = code.match(/(?:variant|size)\??\s*:\s*['"][^'"]+['"]\s*\|/g); if (variantProps && variantProps.length > 0) { // Check if there's JSDoc before the type definition const hasJsDoc = /\/\*\*[\s\S]*?\*\/\s*(?:export\s+)?(?:type|interface)\s+\w+Props/.test(code); const hasInlineJsDoc = /@(?:default|description)/.test(code); if (!hasJsDoc && !hasInlineJsDoc) { violations.push({ ruleId: 'variants-documented', message: 'Variant props should have JSDoc comments', suggestion: 'Add /** @default "primary" */ or descriptive comments above variant props' }); } } return violations; }, example: { bad: `type ButtonProps = { variant?: 'primary' | 'secondary'; size?: 'sm' | 'md' | 'lg'; };`, good: `type ButtonProps = { /** * The visual style of the button * @default "primary" */ variant?: 'primary' | 'secondary'; /** * The size of the button * @default "md" */ size?: 'sm' | 'md' | 'lg'; };` } }, { id: 'extracts-repeated-patterns', name: 'Extracts Repeated Patterns', description: 'Repeated style patterns should be extracted into shared utilities', category: 'styling', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Common patterns that should be extracted const commonPatterns = [ { pattern: /focus-visible:outline-none\s+focus-visible:ring/g, name: 'focus ring' }, { pattern: /disabled:pointer-events-none\s+disabled:opacity/g, name: 'disabled state' }, { pattern: /transition-(?:all|colors|opacity|transform)/g, name: 'transition' }, ]; for (const { pattern, name } of commonPatterns) { const matches = code.match(pattern); if (matches && matches.length >= 3) { violations.push({ ruleId: 'extracts-repeated-patterns', message: `The ${name} pattern appears ${matches.length} times - consider extracting to a shared utility`, suggestion: `Create a utility: export const ${name.replace(' ', '')} = '${matches[0]}'; and import it` }); } } return violations; }, example: { bad: `// Same focus ring copied everywhere <Button className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" /> <Input className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" />`, good: `// utils/styles.ts export const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'; export const disabled = 'disabled:pointer-events-none disabled:opacity-50'; // In components <Button className={cn(focusRing, disabled, className)} />` } }, // ============================================ // ASCHILD PATTERN RULES // ============================================ { id: 'aschild-spreads-props', name: 'asChild Component Spreads Props', description: 'Components used with asChild must spread props to receive parent behavior', category: 'composition', severity: 'error', weight: 10, check: (code) => { const violations: RuleViolation[] = []; // Check for components that DON'T spread props // Pattern: function/const Component = ({ children }) => ... (no ...props) const noSpreadPattern = /(?:const|function)\s+(\w+)\s*=?\s*\(\s*\{\s*(?:children|className)(?:\s*,\s*(?:children|className))*\s*\}\s*\)/g; let match; while ((match = noSpreadPattern.exec(code)) !== null) { const componentName = match[1]; // Skip if this looks like a type definition if (code.includes(`${componentName}Props`)) { violations.push({ ruleId: 'aschild-spreads-props', message: `${componentName} doesn't spread props - won't work correctly with asChild`, suggestion: `Always spread props to receive parent behavior: // ❌ Won't receive trigger behavior const ${componentName} = ({ children }) => <div>{children}</div>; // ✅ Properly receives all props const ${componentName} = ({ children, ...props }) => ( <div {...props}>{children}</div> );` }); } } return violations; }, example: { bad: `// Won't work with asChild - props not spread const Button = ({ children }) => <button>{children}</button>;`, good: `// Works with asChild - props are spread const Button = ({ children, ...props }) => ( <button {...props}>{children}</button> );` } }, // ============================================ // ACCESSIBILITY ADVANCED RULES // ============================================ { id: 'nav-has-label', name: 'Navigation Has Label', description: 'Navigation elements should have aria-label for screen readers', category: 'accessibility', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for <nav> without aria-label const navWithoutLabel = /<nav(?![^>]*aria-label)/g; const matches = code.match(navWithoutLabel); if (matches && matches.length > 0) { violations.push({ ruleId: 'nav-has-label', message: `Found ${matches.length} <nav> element(s) without aria-label`, suggestion: `Add aria-label to describe the navigation: <nav aria-label="Main navigation"> <nav aria-label="Breadcrumb"> <nav aria-label="Footer links">` }); } return violations; }, example: { bad: `<nav> <ul>...</ul> </nav>`, good: `<nav aria-label="Main navigation"> <ul>...</ul> </nav>` } }, { id: 'focus-visible-styles', name: 'Uses Focus Visible', description: 'Use focus-visible instead of focus for keyboard-only focus styles', category: 'accessibility', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check for :focus without :focus-visible const focusWithoutVisible = /(?:^|[^-])focus:/g; const hasFocusVisible = /focus-visible:/.test(code); const matches = code.match(focusWithoutVisible); if (matches && matches.length > 2 && !hasFocusVisible) { violations.push({ ruleId: 'focus-visible-styles', message: 'Using focus: instead of focus-visible: - focus styles will show on mouse click', suggestion: `Use focus-visible: to only show focus styles for keyboard navigation: // ❌ Shows focus ring on mouse click too className="focus:ring-2 focus:ring-blue-500" // ✅ Only shows focus ring for keyboard users className="focus-visible:ring-2 focus-visible:ring-blue-500"` }); } return violations; }, example: { bad: `<button className="focus:outline-none focus:ring-2">`, good: `<button className="focus-visible:outline-none focus-visible:ring-2">` } }, { id: 'input-has-label', name: 'Input Has Associated Label', description: 'Form inputs should have associated labels or aria-label', category: 'accessibility', severity: 'warning', weight: 8, check: (code) => { const violations: RuleViolation[] = []; // Check for input without aria-label and not near a label const inputPattern = /<input(?![^>]*(?:aria-label|aria-labelledby|id=))[^>]*>/g; const matches = code.match(inputPattern); // Only flag if there's no <label> in the code at all const hasLabel = /<label/.test(code); if (matches && matches.length > 0 && !hasLabel) { violations.push({ ruleId: 'input-has-label', message: `Found input(s) without labels or aria-label`, suggestion: `Associate inputs with labels: // Option 1: Wrapping label <label> Email <input type="email" /> </label> // Option 2: Using htmlFor/id <label htmlFor="email">Email</label> <input id="email" type="email" /> // Option 3: aria-label for icon inputs <input type="search" aria-label="Search" />` }); } return violations; }, example: { bad: `<input type="email" placeholder="Email" />`, good: `<label> Email <input type="email" /> </label>` } }, { id: 'prefers-aria-disabled', name: 'Prefers aria-disabled Over disabled', description: 'Consider aria-disabled over disabled to maintain focusability', category: 'accessibility', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check for disabled without aria-disabled explanation const hasDisabled = /disabled(?:\s*=\s*\{|\s*\})/.test(code); const hasAriaDisabled = /aria-disabled/.test(code); const hasExplanation = /aria-describedby|disabled.*help|disabled.*explain/i.test(code); if (hasDisabled && !hasAriaDisabled && !hasExplanation) { violations.push({ ruleId: 'prefers-aria-disabled', message: 'Using disabled attribute - users cannot focus to understand why', suggestion: `Consider aria-disabled with explanation: // ❌ User can't understand why button is disabled <button disabled={!isValid}>Submit</button> // ✅ User can focus and learn why <button aria-disabled={!isValid} aria-describedby="submit-help" onClick={isValid ? handleSubmit : undefined} className={!isValid ? 'opacity-50 cursor-not-allowed' : ''} > Submit </button> <span id="submit-help"> {!isValid && 'Please fill in all required fields'} </span>` }); } return violations; }, example: { bad: `<button disabled={!isValid}>Submit</button>`, good: `<button aria-disabled={!isValid} aria-describedby="submit-help" >Submit</button> <span id="submit-help">{!isValid && 'Complete required fields'}</span>` } }, { id: 'cva-outside-component', name: 'CVA Defined Outside Component', description: 'CVA variants should be defined outside the component to avoid recreation', category: 'styling', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for cva() inside a function component const componentPattern = /(?:function|const)\s+\w+\s*(?:=|[\(<])[\s\S]*?cva\(/g; // More specific: cva inside the component body const cvaInsideComponent = /(?:function|const)\s+\w+\s*(?:=\s*\([^)]*\)\s*=>|[\(<])[^}]*\breturn\b[^}]*\bcva\(/; if (cvaInsideComponent.test(code)) { violations.push({ ruleId: 'cva-outside-component', message: 'CVA variants defined inside component - recreated on every render', suggestion: `Define CVA variants outside the component: // ❌ Recreated on every render function Button({ variant }) { const buttonVariants = cva('...', { variants: {...} }); return <button className={buttonVariants({ variant })} />; } // ✅ Defined once, reused const buttonVariants = cva('...', { variants: {...} }); function Button({ variant }) { return <button className={buttonVariants({ variant })} />; }` }); } return violations; }, example: { bad: `function Button({ variant }) { const buttonVariants = cva('base', { variants: {} }); return <button className={buttonVariants({ variant })} />; }`, good: `const buttonVariants = cva('base', { variants: {} }); function Button({ variant }) { return <button className={buttonVariants({ variant })} />; }` } }, { id: 'live-region-for-dynamic', name: 'Live Region for Dynamic Content', description: 'Dynamic content changes should use aria-live for screen reader announcements', category: 'accessibility', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check for loading states without aria-live const hasLoadingState = /isLoading|loading|\.loading/i.test(code); const hasErrorState = /error|Error|isError/i.test(code); const hasAriaLive = /aria-live|role="alert"|role="status"/.test(code); if ((hasLoadingState || hasErrorState) && !hasAriaLive) { violations.push({ ruleId: 'live-region-for-dynamic', message: 'Dynamic loading/error states should announce to screen readers', suggestion: `Add aria-live for dynamic content: // Loading states <div aria-live="polite" aria-busy={isLoading}> {isLoading ? "Loading..." : \`\${items.length} items\`} </div> // Error messages <div role="alert" aria-live="assertive"> {error && \`Error: \${error.message}\`} </div> // Status updates <div role="status" aria-live="polite"> {saved && "Changes saved"} </div>` }); } return violations; }, example: { bad: `{isLoading && <span>Loading...</span>} {error && <span className="text-red-500">{error}</span>}`, good: `<div aria-live="polite" aria-busy={isLoading}> {isLoading ? "Loading..." : "Content loaded"} </div> <div role="alert">{error}</div>` } }, { id: 'touch-target-size', name: 'Touch Target Size', description: 'Interactive elements should have minimum 44x44px touch targets', category: 'accessibility', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check for small size classes on interactive elements const smallInteractive = /(?:<button|<a\s|onClick)[^>]*(?:size-\d|w-\d|h-\d|p-1|p-0\.5)/g; const matches = code.match(smallInteractive); // Check for size utilities smaller than min touch target (44px ≈ h-11, w-11) const tooSmall = /(?:size-[1-9]|w-[1-9]|h-[1-9]|size-10|w-10|h-10)(?![0-9])/; if (matches && matches.some(m => tooSmall.test(m))) { violations.push({ ruleId: 'touch-target-size', message: 'Interactive element may be too small for touch (min 44x44px)', suggestion: `Ensure minimum 44x44px touch targets: // ❌ Too small for touch <button className="size-6"> <Icon /> </button> // ✅ Minimum touch target <button className="size-11 p-2"> <Icon className="size-6" /> </button> // ✅ Or use invisible touch area <button className="relative size-6"> <span className="absolute -inset-2" /> <!-- Extends touch area --> <Icon /> </button>` }); } return violations; }, example: { bad: `<button className="size-6" onClick={...}> <XIcon /> </button>`, good: `<button className="size-11 p-2" onClick={...}> <XIcon className="size-6" /> </button>` } }, // ============================================ // ARTIFACT CLASSIFICATION RULES // ============================================ { id: 'block-vs-component', name: 'Block vs Component Classification', description: 'Blocks should be production-ready compositions, not reusable components', category: 'composition', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Indicators that this is a BLOCK (correct for blocks): // - Multiple component imports // - Hardcoded content/copy // - Multiple sections/layouts // - Specific use-case names (Hero, Pricing, Features, etc.) const isBlock = /(?:Hero|Pricing|Features|Footer|Header|Auth|Onboarding|Dashboard|Settings|Billing|Chat|Landing)/i.test(code); const hasMultipleImports = (code.match(/import.*from/g) || []).length > 5; const hasHardcodedContent = /"[A-Z][^"]{20,}"|'[A-Z][^']{20,}'/.test(code); // Long strings with capital start // Indicators that this should be a COMPONENT (problem if in a block): // - Exports multiple sub-components (compound pattern) // - Has variants/CVA // - No hardcoded content // - Generic naming (Button, Card, Input) const exportsMultiple = (code.match(/export\s+(?:const|function)/g) || []).length > 3; const hasCVA = /cva\(/.test(code); const hasVariants = /variants?\s*[:=]/.test(code); // If it looks like a block but exports like a component library if (isBlock && exportsMultiple && hasCVA) { violations.push({ ruleId: 'block-vs-component', message: 'This appears to be a Block but exports multiple components with CVA variants', suggestion: `Blocks should: - Be copy-paste friendly compositions - Import components, not define them - Have opinionated content/layout - Not export reusable sub-components with variants If this is meant to be reusable, it should be a Component. If it's a Block, move the sub-components to separate files. From definitions.mdx: "Blocks are typically not reusable like a component. You don't import them, but they typically import components and primitives."` }); } // If it looks like a component but has hardcoded content like a block if (!isBlock && hasHardcodedContent && !hasMultipleImports && hasCVA) { violations.push({ ruleId: 'block-vs-component', message: 'Component has hardcoded content - should this be a Block instead?', suggestion: `Components should: - Accept content via props/children - Not contain hardcoded copy/text - Be reusable across different contexts If this has specific content for a use-case, it's a Block, not a Component.` }); } return violations; }, example: { bad: `// ❌ Block with component-style exports export const HeroTitle = ({ variant }) => cva(...) export const HeroDescription = ({ variant }) => cva(...) export const HeroButton = ({ variant }) => cva(...) // ❌ Component with hardcoded content export const Button = () => ( <button>Get Started for Free Today!</button> )`, good: `// ✅ Block imports components, has opinionated content import { Button } from '@/components/ui/button'; export function HeroBlock() { return ( <section> <h1>Get Started for Free Today!</h1> <Button>Sign Up</Button> </section> ); } // ✅ Component accepts content via props export const Button = ({ children, ...props }) => ( <button {...props}>{children}</button> )` } }, { id: 'component-single-responsibility', name: 'Component Single Responsibility', description: 'Components should wrap a single element, not multiple unrelated elements', category: 'composition', severity: 'warning', weight: 5, check: (code) => { const violations: RuleViolation[] = []; // Check for components that render multiple root-level elements without composition // Pattern: return ( <div>...<div>...<div>... ) without clear compound structure const hasMultipleRootElements = /<(?:div|section|article)[^>]*>[\s\S]*<(?:div|section|article)[^>]*>[\s\S]*<(?:div|section|article)[^>]*>/; // Check if it's using compound component pattern (which is OK) const isCompound = /\w+\.\w+\s*=/.test(code) || /export\s+const\s+\w+\s*=.*Object\.assign/.test(code); // Check if component accepts children (compositional) const acceptsChildren = /children/.test(code); // If it has many nested divs but isn't compound and doesn't accept children if (hasMultipleRootElements.test(code) && !isCompound && !acceptsChildren) { // Check if it's a block (which can have multiple elements) const isBlock = /(?:Hero|Pricing|Features|Footer|Header|Block|Section|Page)/i.test(code); if (!isBlock) { violations.push({ ruleId: 'component-single-responsibility', message: 'Component renders multiple elements - consider composition pattern', suggestion: `From definitions.mdx: Components should wrap a single element. Either: 1. Break into compound sub-components (Card.Header, Card.Content) 2. Accept children for flexible composition 3. If this is a full section/layout, it's a Block, not a Component // ❌ Too much responsibility const Card = ({ title, description, footer }) => ( <div> <div className="header"><h2>{title}</h2></div> <div className="body"><p>{description}</p></div> <div className="footer">{footer}</div> </div> ); // ✅ Composable const Card = ({ children }) => <div>{children}</div>; const CardHeader = ({ children }) => <div>{children}</div>; const CardContent = ({ children }) => <div>{children}</div>;` }); } } return violations; }, example: { bad: `// Component doing too much const Card = ({ title, desc, footer, image }) => ( <div> <img src={image} /> <h2>{title}</h2> <p>{desc}</p> <div>{footer}</div> </div> );`, good: `// Composable compound components const Card = ({ children }) => <div>{children}</div>; Card.Image = ({ src }) => <img src={src} />; Card.Title = ({ children }) => <h2>{children}</h2>; Card.Description = ({ children }) => <p>{children}</p>;` } }, { id: 'primitive-should-be-unstyled', name: 'Primitive Should Be Unstyled', description: 'If building a primitive, it should not include styling', category: 'composition', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check if this is named like a primitive const isPrimitiveName = /(?:Primitive|Headless|Unstyled|Base)/.test(code); // Check for styling const hasStyling = /className=|style=|styled\.|css`/.test(code); const hasTailwind = /(?:bg-|text-|p-|m-|flex|grid|rounded|border)/.test(code); if (isPrimitiveName && (hasStyling || hasTailwind)) { violations.push({ ruleId: 'primitive-should-be-unstyled', message: 'This appears to be a Primitive but includes styling', suggestion: `From definitions.mdx: "A primitive is the lowest-level building block that provides behavior and accessibility WITHOUT any styling." Primitives should: - Be completely unstyled (headless) - Encapsulate behavior, a11y, keyboard interaction - Let consumers provide all styling If you need styling, this is a Component, not a Primitive.` }); } return violations; }, example: { bad: `// Primitive with styling - contradiction const PrimitiveButton = ({ children }) => ( <button className="bg-blue-500 rounded-lg p-2"> {children} </button> );`, good: `// True primitive - behavior only const PrimitiveButton = ({ children, ...props }) => ( <button type="button" role="button" {...props} > {children} </button> );` } }, { id: 'utility-should-not-render', name: 'Utility Should Not Render UI', description: 'Utilities should be non-visual helpers, not components', category: 'composition', severity: 'info', weight: 3, check: (code) => { const violations: RuleViolation[] = []; // Check if this is named like a utility const isUtilityName = /(?:use[A-Z]|util|helper|hook)/i.test(code); const isExportedFunction = /export\s+(?:const|function)\s+(?:use[A-Z]|create|get|format|parse|is[A-Z])/.test(code); // Check if it renders JSX const rendersJSX = /<[A-Z]|<[a-z]+[^>]*>/.test(code); const hasReturn = /return\s*\(?\s*</.test(code); if ((isUtilityName || isExportedFunction) && (rendersJSX && hasReturn)) { // Exception: hooks can return elements const isHookReturningElement = /use[A-Z]\w*.*return.*</.test(code); if (!isHookReturningElement) { violations.push({ ruleId: 'utility-should-not-render', message: 'This appears to be a Utility but renders UI', suggestion: `From definitions.mdx: "A utility is a helper exported for developer ergonomics or composition; NOT rendered UI." Utilities should: - Be side-effect free - Not render any JSX - Be testable in isolation If this renders UI, it's a Component, not a Utility.` }); } } return violations; }, example: { bad: `// Utility that renders - contradiction export function formatDate(date) { return <span>{date.toLocaleDateString()}</span>; }`, good: `// True utility - returns data, not UI export function formatDate(date: Date): string { return date.toLocaleDateString(); } // Component uses the utility const DateDisplay = ({ date }) => ( <span>{formatDate(date)}</span> );` } } ]; /** * Grade a component against all rules */ export function gradeComponent(code: string): GradeResult { const violations: RuleViolation[] = []; const passes: string[] = []; let totalWeight = 0; let lostPoints = 0; for (const rule of RULES) { totalWeight += rule.weight; const ruleViolations = rule.check(code); if (ruleViolations.length === 0) { passes.push(rule.id); } else { violations.push(...ruleViolations); lostPoints += rule.weight; } } const score = Math.max(0, Math.round(((totalWeight - lostPoints) / totalWeight) * 100)); let grade: string; if (score >= 95) grade = 'A+'; else if (score >= 90) grade = 'A'; else if (score >= 85) grade = 'B+'; else if (score >= 80) grade = 'B'; else if (score >= 75) grade = 'C+'; else if (score >= 70) grade = 'C'; else if (score >= 65) grade = 'D+'; else if (score >= 60) grade = 'D'; else grade = 'F'; const errorCount = violations.filter(v => RULES.find(r => r.id === v.ruleId)?.severity === 'error' ).length; const warningCount = violations.filter(v => RULES.find(r => r.id === v.ruleId)?.severity === 'warning' ).length; const summary = `Score: ${score}/100 (${grade}) | ${passes.length} passed | ${errorCount} errors | ${warningCount} warnings`; return { score, grade, violations, passes, summary }; } /** * Get all rules with full documentation */ export function getAllRules(): Rule[] { return RULES; } /** * Get rules by category */ export function getRulesByCategory(category: Rule['category']): Rule[] { return RULES.filter(r => r.category === category); } /** * Get a specific rule by ID */ export function getRule(id: string): Rule | undefined { return RULES.find(r => r.id === id); } /** * Get the full rules documentation as markdown */ export function getRulesMarkdown(): string { let md = `# Component Rules Reference > Based on components.build specification by Hayden Bleasel and shadcn. --- `; const categories = [...new Set(RULES.map(r => r.category))]; for (const category of categories) { const categoryRules = RULES.filter(r => r.category === category); md += `## ${category.charAt(0).toUpperCase() + category.slice(1)} Rules\n\n`; for (const rule of categoryRules) { md += `### ${rule.name}\n`; md += `**ID:** \`${rule.id}\` | **Severity:** ${rule.severity} | **Weight:** ${rule.weight}\n\n`; md += `${rule.description}\n\n`; md += `**Bad:**\n\`\`\`tsx\n${rule.example.bad}\n\`\`\`\n\n`; md += `**Good:**\n\`\`\`tsx\n${rule.example.good}\n\`\`\`\n\n`; md += `---\n\n`; } } return md; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/audreyui/components-build-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server