senior-frontend
Frontend development skill for React, Next.js, TypeScript, and Tailwind CSS applications. Use when building React components, optimizing Next.js performance, analyzing bundle sizes, scaffolding frontend projects, implementing accessibility, or reviewing frontend code quality.
Install
npx skills add https://github.com/sickn33/agentic-awesome-skills/tree/main/skills/senior-frontend
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install sickn33-agentic-awesome-skills@llmmart
git clone https://github.com/sickn33/agentic-awesome-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole sickn33/agentic-awesome-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Senior Frontend
Frontend development patterns, performance optimization, and automation tools for React/Next.js applications.
When to Use
- Use when scaffolding a new React or Next.js project with TypeScript and Tailwind CSS.
- Use when generating new components or custom hooks.
- Use when analyzing and optimizing bundle sizes for frontend applications.
- Use to implement or review advanced React patterns like Compound Components or Render Props.
- Use to ensure accessibility compliance and implement robust testing strategies.
Table of Contents
- Project Scaffolding
- Component Generation
- Bundle Analysis
- React Patterns
- Next.js Optimization
- Accessibility and Testing
Project Scaffolding
Generate a new Next.js or React project with TypeScript, Tailwind CSS, and best practice configurations.
Workflow: Create New Frontend Project
Run the scaffolder with your project name and template:
python scripts/frontend_scaffolder.py my-app --template nextjsAdd optional features (auth, api, forms, testing, storybook):
python scripts/frontend_scaffolder.py dashboard --template nextjs --features auth,apiNavigate to the project and install dependencies:
cd my-app && npm installStart the development server:
npm run dev
Scaffolder Options
| Option | Description |
|---|---|
--template nextjs |
Next.js 14+ with App Router and Server Components |
--template react |
React + Vite with TypeScript |
--features auth |
Add NextAuth.js authentication |
--features api |
Add React Query + API client |
--features forms |
Add React Hook Form + Zod validation |
--features testing |
Add Vitest + Testing Library |
--dry-run |
Preview files without creating them |
Generated Structure (Next.js)
my-app/
├── app/
│ ├── layout.tsx # Root layout with fonts
│ ├── page.tsx # Home page
│ ├── globals.css # Tailwind + CSS variables
│ └── api/health/route.ts
├── components/
│ ├── ui/ # Button, Input, Card
│ └── layout/ # Header, Footer, Sidebar
├── hooks/ # useDebounce, useLocalStorage
├── lib/ # utils (cn), constants
├── types/ # TypeScript interfaces
├── tailwind.config.ts
├── next.config.js
└── package.json
Component Generation
Generate React components with TypeScript, tests, and Storybook stories.
Workflow: Create a New Component
Generate a client component:
python scripts/component_generator.py Button --dir src/components/uiGenerate a server component:
python scripts/component_generator.py ProductCard --type serverGenerate with test and story files:
python scripts/component_generator.py UserProfile --with-test --with-storyGenerate a custom hook:
python scripts/component_generator.py FormValidation --type hook
Generator Options
| Option | Description |
|---|---|
--type client |
Client component with 'use client' (default) |
--type server |
Async server component |
--type hook |
Custom React hook |
--with-test |
Include test file |
--with-story |
Include Storybook story |
--flat |
Create in output dir without subdirectory |
--dry-run |
Preview without creating files |
Generated Component Example
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface ButtonProps {
className?: string;
children?: React.ReactNode;
}
export function Button({ className, children }: ButtonProps) {
return <div className={cn("", className)}>{children}</div>;
}
Bundle Analysis
Analyze package.json and project structure for bundle optimization opportunities.
Workflow: Optimize Bundle Size
Run the analyzer on your project:
python scripts/bundle_analyzer.py /path/to/projectReview the health score and issues:
Bundle Health Score: 75/100 (C) HEAVY DEPENDENCIES: moment (290KB) Alternative: date-fns (12KB) or dayjs (2KB) lodash (71KB) Alternative: lodash-es with tree-shakingApply the recommended fixes by replacing heavy dependencies.
Re-run with verbose mode to check import patterns:
python scripts/bundle_analyzer.py . --verbose
Bundle Score Interpretation
| Score | Grade | Action |
|---|---|---|
| 90-100 | A | Bundle is well-optimized |
| 80-89 | B | Minor optimizations available |
| 70-79 | C | Replace heavy dependencies |
| 60-69 | D | Multiple issues need attention |
| 0-59 | F | Critical bundle size problems |
Heavy Dependencies Detected
The analyzer identifies these common heavy packages:
| Package | Size | Alternative |
|---|---|---|
| moment | 290KB | date-fns (12KB) or dayjs (2KB) |
| lodash | 71KB | lodash-es with tree-shaking |
| axios | 14KB | Native fetch or ky (3KB) |
| jquery | 87KB | Native DOM APIs |
| @mui/material | Large | shadcn/ui or Radix UI |
React Patterns
Reference: references/react_patterns.md
Compound Components
Share state between related components:
const Tabs = ({ children }) => {
const [active, setActive] = useState(0);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
};
Tabs.List = TabList;
Tabs.Panel = TabPanel;
// Usage
<Tabs>
<Tabs.List>
<Tabs.Tab>One</Tabs.Tab>
<Tabs.Tab>Two</Tabs.Tab>
</Tabs.List>
<Tabs.Panel>Content 1</Tabs.Panel>
<Tabs.Panel>Content 2</Tabs.Panel>
</Tabs>;
Custom Hooks
Extract reusable logic:
function useDebounce<T>(value: T, delay = 500): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage
const debouncedSearch = useDebounce(searchTerm, 300);
Render Props
Share rendering logic:
function DataFetcher({ url, render }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then(setData)
.finally(() => setLoading(false));
}, [url]);
return render({ data, loading });
}
// Usage
<DataFetcher
url="/api/users"
render={({ data, loading }) =>
loading ? <Spinner /> : <UserList users={data} />
}
/>;
Next.js Optimization
Reference: references/nextjs_optimization_guide.md
Server vs Client Components
Use Server Components by default. Add 'use client' only when you need:
- Event handlers (onClick, onChange)
- State (useState, useReducer)
- Effects (useEffect)
- Browser APIs
// Server Component (default) - no 'use client'
async function ProductPage({ params }) {
const product = await getProduct(params.id); // Server-side fetch
return (
<div>
<h1>{product.name}</h1>
<AddToCartButton productId={product.id} /> {/* Client component */}
</div>
);
}
// Client Component
("use client");
function AddToCartButton({ productId }) {
const [adding, setAdding] = useState(false);
return <button onClick={() => addToCart(productId)}>Add</button>;
}
Image Optimization
import Image from 'next/image';
// Above the fold - load immediately
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>
// Responsive image with fill
<div className="relative aspect-video">
<Image
src="/product.jpg"
alt="Product"
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>
Data Fetching Patterns
// Parallel fetching
async function Dashboard() {
const [user, stats] = await Promise.all([getUser(), getStats()]);
return <div>...</div>;
}
// Streaming with Suspense
async function ProductPage({ params }) {
return (
<div>
<ProductDetails id={params.id} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
</div>
);
}
Accessibility and Testing
Reference: references/frontend_best_practices.md
Accessibility Checklist
- Semantic HTML: Use proper elements (
<button>,<nav>,<main>) - Keyboard Navigation: All interactive elements focusable
- ARIA Labels: Provide labels for icons and complex widgets
- Color Contrast: Minimum 4.5:1 for normal text
- Focus Indicators: Visible focus states
// Accessible button
<button
type="button"
aria-label="Close dialog"
onClick={onClose}
className="focus-visible:ring-2 focus-visible:ring-blue-500"
>
<XIcon aria-hidden="true" />
</button>
// Skip link for keyboard users
<a href="#main-content" className="sr-only focus:not-sr-only">
Skip to main content
</a>
Testing Strategy
// Component test with React Testing Library
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("button triggers action on click", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click me</Button>);
await userEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
// Test accessibility
test("dialog is accessible", async () => {
render(<Dialog open={true} title="Confirm" />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toHaveAttribute("aria-labelledby");
});
Quick Reference
Common Next.js Config
// next.config.js
const nextConfig = {
images: {
remotePatterns: [{ hostname: "cdn.example.com" }],
formats: ["image/avif", "image/webp"],
},
experimental: {
optimizePackageImports: ["lucide-react", "@heroicons/react"],
},
};
Tailwind CSS Utilities
// Conditional classes with cn()
import { cn } from "@/lib/utils";
<button
className={cn(
"px-4 py-2 rounded",
variant === "primary" && "bg-blue-500 text-white",
disabled && "opacity-50 cursor-not-allowed",
)}
/>;
TypeScript Patterns
// Props with children
interface CardProps {
className?: string;
children: React.ReactNode;
}
// Generic component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map(renderItem)}</ul>;
}
Resources
- React Patterns:
references/react_patterns.md - Next.js Optimization:
references/nextjs_optimization_guide.md - Best Practices:
references/frontend_best_practices.md
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
Files (agentic-awesome-skills)
-
references
-
frontend_best_practices.md 19.3 KB
# Frontend Best Practices Modern frontend development standards for accessibility, testing, TypeScript, and Tailwind CSS. --- ## Table of Contents - [Accessibility (a11y)](#accessibility-a11y) - [Testing Strategies](#testing-strategies) - [TypeScript Patterns](#typescript-patterns) - [Tailwind CSS](#tailwind-css) - [Project Structure](#project-structure) - [Security](#security) --- ## Accessibility (a11y) ### Semantic HTML ```tsx // BAD - Divs for everything <div onClick={handleClick}>Click me</div> <div class="header">...</div> <div class="nav">...</div> // GOOD - Semantic elements <button onClick={handleClick}>Click me</button> <header>...</header> <nav>...</nav> <main>...</main> <article>...</article> <aside>...</aside> <footer>...</footer> ``` ### Keyboard Navigation ```tsx // Ensure all interactive elements are keyboard accessible function Modal({ isOpen, onClose, children }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); useEffect(() => { if (isOpen) { // Focus first focusable element const focusable = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); (focusable?.[0] as HTMLElement)?.focus(); // Trap focus within modal const handleTab = (e: KeyboardEvent) => { if (e.key === 'Tab' && focusable) { const first = focusable[0] as HTMLElement; const last = focusable[focusable.length - 1] as HTMLElement; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } if (e.key === 'Escape') { onClose(); } }; document.addEventListener('keydown', handleTab); return () => document.removeEventListener('keydown', handleTab); } }, [isOpen, onClose]); if (!isOpen) return null; return ( <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" > {children} </div> ); } ``` ### ARIA Attributes ```tsx // Live regions for dynamic content <div aria-live="polite" aria-atomic="true"> {status && <p>{status}</p>} </div> // Loading states <button disabled={isLoading} aria-busy={isLoading}> {isLoading ? 'Loading...' : 'Submit'} </button> // Form labels <label htmlFor="email">Email address</label> <input id="email" type="email" aria-required="true" aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} /> {errors.email && ( <p id="email-error" role="alert"> {errors.email} </p> )} // Navigation <nav aria-label="Main navigation"> <ul> <li><a href="/" aria-current={isHome ? 'page' : undefined}>Home</a></li> <li><a href="/about" aria-current={isAbout ? 'page' : undefined}>About</a></li> </ul> </nav> // Toggle buttons <button aria-pressed={isEnabled} onClick={() => setIsEnabled(!isEnabled)} > {isEnabled ? 'Enabled' : 'Disabled'} </button> // Expandable sections <button aria-expanded={isOpen} aria-controls="content-panel" onClick={() => setIsOpen(!isOpen)} > Show details </button> <div id="content-panel" hidden={!isOpen}> Content here </div> ``` ### Color Contrast ```tsx // Ensure 4.5:1 contrast ratio for text (WCAG AA) // Use tools like @axe-core/react for testing // tailwind.config.js - Define accessible colors module.exports = { theme: { colors: { // Primary with proper contrast primary: { DEFAULT: '#2563eb', // Blue 600 foreground: '#ffffff', }, // Error state error: { DEFAULT: '#dc2626', // Red 600 foreground: '#ffffff', }, // Text colors with proper contrast foreground: '#0f172a', // Slate 900 muted: '#64748b', // Slate 500 - minimum 4.5:1 on white }, }, }; // Never rely on color alone <span className="text-red-600"> <ErrorIcon aria-hidden="true" /> <span>Error: Invalid input</span> </span> ``` ### Screen Reader Only Content ```tsx // Visually hidden but accessible to screen readers const srOnly = 'absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0'; // Skip link for keyboard users <a href="#main-content" className={srOnly + ' focus:not-sr-only focus:absolute focus:top-0'}> Skip to main content </a> // Icon buttons need labels <button aria-label="Close menu"> <XIcon aria-hidden="true" /> </button> // Or use visually hidden text <button> <XIcon aria-hidden="true" /> <span className={srOnly}>Close menu</span> </button> ``` --- ## Testing Strategies ### Component Testing with Testing Library ```tsx // Button.test.tsx import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Button } from './Button'; describe('Button', () => { it('renders with correct text', () => { render(<Button>Click me</Button>); expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument(); }); it('calls onClick when clicked', async () => { const user = userEvent.setup(); const handleClick = jest.fn(); render(<Button onClick={handleClick}>Click me</Button>); await user.click(screen.getByRole('button')); expect(handleClick).toHaveBeenCalledTimes(1); }); it('is disabled when loading', () => { render(<Button isLoading>Submit</Button>); expect(screen.getByRole('button')).toBeDisabled(); expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); }); it('shows loading text when loading', () => { render(<Button isLoading loadingText="Submitting...">Submit</Button>); expect(screen.getByText('Submitting...')).toBeInTheDocument(); }); }); ``` ### Hook Testing ```tsx // useCounter.test.ts import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; describe('useCounter', () => { it('initializes with default value', () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); }); it('initializes with custom value', () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10); }); it('increments count', () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); it('resets to initial value', () => { const { result } = renderHook(() => useCounter(5)); act(() => { result.current.increment(); result.current.increment(); result.current.reset(); }); expect(result.current.count).toBe(5); }); }); ``` ### Integration Testing ```tsx // LoginForm.test.tsx import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { LoginForm } from './LoginForm'; import { AuthProvider } from '@/contexts/AuthContext'; const mockLogin = jest.fn(); jest.mock('@/lib/auth', () => ({ login: (...args: unknown[]) => mockLogin(...args), })); describe('LoginForm', () => { beforeEach(() => { mockLogin.mockReset(); }); it('submits form with valid credentials', async () => { const user = userEvent.setup(); mockLogin.mockResolvedValueOnce({ user: { id: '1', name: 'Test' } }); render( <AuthProvider> <LoginForm /> </AuthProvider> ); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); await user.click(screen.getByRole('button', { name: /sign in/i })); await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123'); }); }); it('shows validation errors for empty fields', async () => { const user = userEvent.setup(); render( <AuthProvider> <LoginForm /> </AuthProvider> ); await user.click(screen.getByRole('button', { name: /sign in/i })); expect(await screen.findByText(/email is required/i)).toBeInTheDocument(); expect(await screen.findByText(/password is required/i)).toBeInTheDocument(); expect(mockLogin).not.toHaveBeenCalled(); }); }); ``` ### E2E Testing with Playwright ```typescript // e2e/checkout.spec.ts import { test, expect } from '@playwright/test'; test.describe('Checkout flow', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.click('[data-testid="product-1"] button'); await page.click('[data-testid="cart-button"]'); }); test('completes checkout with valid payment', async ({ page }) => { await page.click('text=Proceed to Checkout'); // Fill shipping info await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="address"]', '123 Test St'); await page.fill('[name="city"]', 'Test City'); await page.selectOption('[name="state"]', 'CA'); await page.fill('[name="zip"]', '90210'); await page.click('text=Continue to Payment'); await page.click('text=Place Order'); // Verify success await expect(page).toHaveURL(/\/order\/confirmation/); await expect(page.locator('h1')).toHaveText('Order Confirmed!'); }); }); ``` --- ## TypeScript Patterns ### Component Props ```tsx // Use interface for component props interface ButtonProps { variant?: 'primary' | 'secondary' | 'ghost'; size?: 'sm' | 'md' | 'lg'; isLoading?: boolean; children: React.ReactNode; onClick?: () => void; } // Extend HTML attributes interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary'; isLoading?: boolean; } function Button({ variant = 'primary', isLoading, children, ...props }: ButtonProps) { return ( <button {...props} disabled={props.disabled || isLoading} className={cn(variants[variant], props.className)} > {isLoading ? <Spinner /> : children} </button> ); } // Polymorphic components type PolymorphicProps<E extends React.ElementType> = { as?: E; } & React.ComponentPropsWithoutRef<E>; function Box<E extends React.ElementType = 'div'>({ as, children, ...props }: PolymorphicProps<E>) { const Component = as || 'div'; return <Component {...props}>{children}</Component>; } // Usage <Box as="section" id="hero">Content</Box> <Box as="article">Article content</Box> ``` ### Discriminated Unions ```tsx // State machines with exhaustive type checking type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }; function DataDisplay<T>({ state, render }: { state: AsyncState<T>; render: (data: T) => React.ReactNode; }) { switch (state.status) { case 'idle': return null; case 'loading': return <Spinner />; case 'success': return <>{render(state.data)}</>; case 'error': return <ErrorMessage error={state.error} />; // TypeScript ensures all cases are handled } } ``` ### Generic Components ```tsx // Generic list component interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string; emptyMessage?: string; } function List<T>({ items, renderItem, keyExtractor, emptyMessage }: ListProps<T>) { if (items.length === 0) { return <p className="text-muted">{emptyMessage || 'No items'}</p>; } return ( <ul> {items.map((item, index) => ( <li key={keyExtractor(item)}>{renderItem(item, index)}</li> ))} </ul> ); } // Usage <List items={users} keyExtractor={(user) => user.id} renderItem={(user) => <UserCard user={user} />} /> ``` ### Type Guards ```tsx // User-defined type guards interface User { id: string; name: string; email: string; } interface Admin extends User { role: 'admin'; permissions: string[]; } function isAdmin(user: User): user is Admin { return 'role' in user && user.role === 'admin'; } function UserBadge({ user }: { user: User }) { if (isAdmin(user)) { // TypeScript knows user is Admin here return <Badge variant="admin">Admin ({user.permissions.length} perms)</Badge>; } return <Badge>User</Badge>; } // API response type guards interface ApiSuccess<T> { success: true; data: T; } interface ApiError { success: false; error: string; } type ApiResponse<T> = ApiSuccess<T> | ApiError; function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccess<T> { return response.success === true; } ``` --- ## Tailwind CSS ### Component Variants with CVA ```tsx import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( // Base styles 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500', secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500', ghost: 'hover:bg-gray-100 hover:text-gray-900', destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500', }, size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base', icon: 'h-10 w-10', }, }, defaultVariants: { variant: 'primary', size: 'md', }, } ); interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {} function Button({ className, variant, size, ...props }: ButtonProps) { return ( <button className={cn(buttonVariants({ variant, size }), className)} {...props} /> ); } // Usage <Button variant="primary" size="lg">Large Primary</Button> <Button variant="ghost" size="icon"><MenuIcon /></Button> ``` ### Responsive Design ```tsx // Mobile-first responsive design <div className=" grid grid-cols-1 {/* Mobile: 1 column */} sm:grid-cols-2 {/* 640px+: 2 columns */} lg:grid-cols-3 {/* 1024px+: 3 columns */} xl:grid-cols-4 {/* 1280px+: 4 columns */} gap-4 sm:gap-6 lg:gap-8 "> {products.map(product => <ProductCard key={product.id} product={product} />)} </div> // Container with responsive padding <div className="container mx-auto px-4 sm:px-6 lg:px-8"> Content </div> // Hide/show based on breakpoint <nav className="hidden md:flex">Desktop nav</nav> <button className="md:hidden">Mobile menu</button> ``` ### Animation Utilities ```tsx // Skeleton loading <div className="animate-pulse space-y-4"> <div className="h-4 bg-gray-200 rounded w-3/4" /> <div className="h-4 bg-gray-200 rounded w-1/2" /> </div> // Transitions <button className=" transition-all duration-200 ease-in-out hover:scale-105 active:scale-95 "> Hover me </button> // Custom animations in tailwind.config.js module.exports = { theme: { extend: { animation: { 'fade-in': 'fadeIn 0.3s ease-out', 'slide-up': 'slideUp 0.3s ease-out', 'spin-slow': 'spin 3s linear infinite', }, keyframes: { fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' }, }, slideUp: { '0%': { transform: 'translateY(10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' }, }, }, }, }, }; // Usage <div className="animate-fade-in">Fading in</div> ``` --- ## Project Structure ### Feature-Based Structure ``` src/ ├── app/ # Next.js App Router │ ├── (auth)/ # Auth route group │ │ ├── login/ │ │ └── register/ │ ├── dashboard/ │ │ ├── page.tsx │ │ └── layout.tsx │ └── layout.tsx ├── components/ │ ├── ui/ # Shared UI components │ │ ├── Button.tsx │ │ ├── Input.tsx │ │ └── index.ts │ └── features/ # Feature-specific components │ ├── auth/ │ │ ├── LoginForm.tsx │ │ └── RegisterForm.tsx │ └── dashboard/ │ ├── StatsCard.tsx │ └── RecentActivity.tsx ├── hooks/ # Custom React hooks │ ├── useAuth.ts │ ├── useDebounce.ts │ └── useLocalStorage.ts ├── lib/ # Utilities and configs │ ├── utils.ts │ ├── api.ts │ └── constants.ts ├── types/ # TypeScript types │ ├── user.ts │ └── api.ts └── styles/ └── globals.css ``` ### Barrel Exports ```tsx // components/ui/index.ts export { Button } from './Button'; export { Input } from './Input'; export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Dialog, DialogTrigger, DialogContent } from './Dialog'; // Usage import { Button, Input, Card } from '@/components/ui'; ``` --- ## Security ### XSS Prevention React escapes content by default, which prevents most XSS attacks. When you need to render HTML content: 1. **Avoid rendering raw HTML** when possible 2. **Sanitize with DOMPurify** for trusted content sources 3. **Use allow-lists** for permitted tags and attributes ```tsx // React escapes by default - this is safe <div>{userInput}</div> // When you must render HTML, sanitize first import DOMPurify from 'dompurify'; function SafeHTML({ html }: { html: string }) { const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'], ALLOWED_ATTR: ['href'], }); return <div dangerouslySetInnerHTML={{ __html: sanitized }} />; } ``` ### Input Validation ```tsx import { z } from 'zod'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; const schema = z.object({ email: z.string().email('Invalid email address'), password: z.string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Password must contain uppercase letter') .regex(/[0-9]/, 'Password must contain number'), confirmPassword: z.string(), }).refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], }); type FormData = z.infer<typeof schema>; function RegisterForm() { const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema), }); return ( <form onSubmit={handleSubmit(onSubmit)}> <Input {...register('email')} error={errors.email?.message} /> <Input type="password" {...register('password')} error={errors.password?.message} /> <Input type="password" {...register('confirmPassword')} error={errors.confirmPassword?.message} /> <Button type="submit">Register</Button> </form> ); } ``` ### Secure API Calls ```tsx // Use environment variables for API endpoints const API_URL = process.env.NEXT_PUBLIC_API_URL; // Never include secrets in client code - use server-side API routes // app/api/data/route.ts export async function GET() { const response = await fetch('https://api.example.com/data', { headers: { 'Authorization': `Bearer ${process.env.API_SECRET}`, // Server-side only }, }); return Response.json(await response.json()); } ``` -
nextjs_optimization_guide.md 14.8 KB
# Next.js Optimization Guide Performance optimization techniques for Next.js 14+ applications. --- ## Table of Contents - [Rendering Strategies](#rendering-strategies) - [Image Optimization](#image-optimization) - [Code Splitting](#code-splitting) - [Data Fetching](#data-fetching) - [Caching Strategies](#caching-strategies) - [Bundle Optimization](#bundle-optimization) - [Core Web Vitals](#core-web-vitals) --- ## Rendering Strategies ### Server Components (Default) Server Components render on the server and send HTML to the client. Use for data-heavy, non-interactive content. ```tsx // app/products/page.tsx - Server Component (default) async function ProductsPage() { // This runs on the server - no client bundle impact const products = await db.products.findMany(); return ( <div className="grid grid-cols-3 gap-4"> {products.map(product => ( <ProductCard key={product.id} product={product} /> ))} </div> ); } ``` ### Client Components Use `'use client'` only when you need: - Event handlers (onClick, onChange) - State (useState, useReducer) - Effects (useEffect) - Browser APIs (window, document) ```tsx 'use client'; import { useState } from 'react'; function AddToCartButton({ productId }: { productId: string }) { const [isAdding, setIsAdding] = useState(false); async function handleClick() { setIsAdding(true); await addToCart(productId); setIsAdding(false); } return ( <button onClick={handleClick} disabled={isAdding}> {isAdding ? 'Adding...' : 'Add to Cart'} </button> ); } ``` ### Mixing Server and Client Components ```tsx // app/products/[id]/page.tsx - Server Component async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id); return ( <div> {/* Server-rendered content */} <h1>{product.name}</h1> <p>{product.description}</p> {/* Client component for interactivity */} <AddToCartButton productId={product.id} /> {/* Server component for reviews */} <ProductReviews productId={product.id} /> </div> ); } ``` ### Static vs Dynamic Rendering ```tsx // Force static generation at build time export const dynamic = 'force-static'; // Force dynamic rendering at request time export const dynamic = 'force-dynamic'; // Revalidate every 60 seconds (ISR) export const revalidate = 60; // Revalidate on-demand import { revalidatePath, revalidateTag } from 'next/cache'; async function updateProduct(id: string, data: ProductData) { await db.products.update({ where: { id }, data }); // Revalidate specific path revalidatePath(`/products/${id}`); // Or revalidate by tag revalidateTag('products'); } ``` --- ## Image Optimization ### Next.js Image Component ```tsx import Image from 'next/image'; // Basic optimized image <Image src="/hero.jpg" alt="Hero image" width={1200} height={600} priority // Load immediately for LCP /> // Responsive image <Image src="/product.jpg" alt="Product" fill sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="object-cover" /> // With placeholder blur import productImage from '@/public/product.jpg'; <Image src={productImage} alt="Product" placeholder="blur" // Uses imported image data /> ``` ### Remote Images Configuration ```js // next.config.js module.exports = { images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**', }, { protocol: 'https', hostname: '*.cloudinary.com', }, ], // Image formats (webp is default) formats: ['image/avif', 'image/webp'], // Device sizes for srcset deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], // Image sizes for srcset imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, }; ``` ### Lazy Loading Patterns ```tsx // Images below the fold - lazy load (default) <Image src="/gallery/photo1.jpg" alt="Gallery photo" width={400} height={300} loading="lazy" // Default behavior /> // Above the fold - load immediately <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority loading="eager" /> ``` --- ## Code Splitting ### Dynamic Imports ```tsx import dynamic from 'next/dynamic'; // Basic dynamic import const HeavyChart = dynamic(() => import('@/components/HeavyChart'), { loading: () => <ChartSkeleton />, }); // Disable SSR for client-only components const MapComponent = dynamic(() => import('@/components/Map'), { ssr: false, loading: () => <div className="h-[400px] bg-gray-100" />, }); // Named exports const Modal = dynamic(() => import('@/components/ui').then(mod => mod.Modal) ); // With suspense const DashboardCharts = dynamic(() => import('@/components/DashboardCharts'), { loading: () => <Suspense fallback={<ChartsSkeleton />} />, }); ``` ### Route-Based Splitting ```tsx // app/dashboard/analytics/page.tsx // This page only loads when /dashboard/analytics is visited import { Suspense } from 'react'; import AnalyticsCharts from './AnalyticsCharts'; export default function AnalyticsPage() { return ( <Suspense fallback={<AnalyticsSkeleton />}> <AnalyticsCharts /> </Suspense> ); } ``` ### Parallel Routes for Code Splitting ``` app/ ├── dashboard/ │ ├── @analytics/ │ │ └── page.tsx # Loaded in parallel │ ├── @metrics/ │ │ └── page.tsx # Loaded in parallel │ ├── layout.tsx │ └── page.tsx ``` ```tsx // app/dashboard/layout.tsx export default function DashboardLayout({ children, analytics, metrics, }: { children: React.ReactNode; analytics: React.ReactNode; metrics: React.ReactNode; }) { return ( <div className="grid grid-cols-2 gap-4"> {children} <Suspense fallback={<AnalyticsSkeleton />}>{analytics}</Suspense> <Suspense fallback={<MetricsSkeleton />}>{metrics}</Suspense> </div> ); } ``` --- ## Data Fetching ### Server-Side Data Fetching ```tsx // Parallel data fetching async function Dashboard() { // Start both requests simultaneously const [user, stats, notifications] = await Promise.all([ getUser(), getStats(), getNotifications(), ]); return ( <div> <UserHeader user={user} /> <StatsPanel stats={stats} /> <NotificationList notifications={notifications} /> </div> ); } ``` ### Streaming with Suspense ```tsx import { Suspense } from 'react'; async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id); return ( <div> {/* Immediate content */} <h1>{product.name}</h1> <p>{product.description}</p> {/* Stream reviews - don't block page */} <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={params.id} /> </Suspense> {/* Stream recommendations */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations productId={params.id} /> </Suspense> </div> ); } // Slow data component async function Reviews({ productId }: { productId: string }) { const reviews = await getReviews(productId); // Slow query return <ReviewList reviews={reviews} />; } ``` ### Request Memoization ```tsx // Next.js automatically dedupes identical requests async function Layout({ children }) { const user = await getUser(); // Request 1 return <div>{children}</div>; } async function Header() { const user = await getUser(); // Same request - cached! return <div>Hello, {user.name}</div>; } // Both components call getUser() but only one request is made ``` --- ## Caching Strategies ### Fetch Cache Options ```tsx // Cache indefinitely (default for static) fetch('https://api.example.com/data'); // No cache - always fresh fetch('https://api.example.com/data', { cache: 'no-store' }); // Revalidate after time fetch('https://api.example.com/data', { next: { revalidate: 3600 } // 1 hour }); // Tag-based revalidation fetch('https://api.example.com/products', { next: { tags: ['products'] } }); // Later, revalidate by tag import { revalidateTag } from 'next/cache'; revalidateTag('products'); ``` ### Route Segment Config ```tsx // app/products/page.tsx // Revalidate every hour export const revalidate = 3600; // Or force dynamic export const dynamic = 'force-dynamic'; // Generate static params at build export async function generateStaticParams() { const products = await getProducts(); return products.map(p => ({ id: p.id })); } ``` ### unstable_cache for Custom Caching ```tsx import { unstable_cache } from 'next/cache'; const getCachedUser = unstable_cache( async (userId: string) => { const user = await db.users.findUnique({ where: { id: userId } }); return user; }, ['user-cache'], { revalidate: 3600, // 1 hour tags: ['users'], } ); // Usage const user = await getCachedUser(userId); ``` --- ## Bundle Optimization ### Analyze Bundle Size ```bash # Install analyzer npm install @next/bundle-analyzer # Update next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({ // config }); # Run analysis ANALYZE=true npm run build ``` ### Tree Shaking Imports ```tsx // BAD - Imports entire library import _ from 'lodash'; const result = _.debounce(fn, 300); // GOOD - Import only what you need import debounce from 'lodash/debounce'; const result = debounce(fn, 300); // GOOD - Named imports (tree-shakeable) import { debounce } from 'lodash-es'; ``` ### Optimize Dependencies ```js // next.config.js module.exports = { // Transpile specific packages transpilePackages: ['ui-library', 'shared-utils'], // Optimize package imports experimental: { optimizePackageImports: ['lucide-react', '@heroicons/react'], }, // External packages for server serverExternalPackages: ['sharp', 'bcrypt'], }; ``` ### Font Optimization ```tsx // app/layout.tsx import { Inter, Roboto_Mono } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter', }); const robotoMono = Roboto_Mono({ subsets: ['latin'], display: 'swap', variable: '--font-roboto-mono', }); export default function RootLayout({ children }) { return ( <html lang="en" className={`${inter.variable} ${robotoMono.variable}`}> <body className="font-sans">{children}</body> </html> ); } ``` --- ## Core Web Vitals ### Largest Contentful Paint (LCP) ```tsx // Optimize LCP hero image import Image from 'next/image'; export default function Hero() { return ( <section className="relative h-[600px]"> <Image src="/hero.jpg" alt="Hero" fill priority // Preload for LCP sizes="100vw" className="object-cover" /> <div className="relative z-10"> <h1>Welcome</h1> </div> </section> ); } // Preload critical resources in layout export default function RootLayout({ children }) { return ( <html> <head> <link rel="preload" href="/hero.jpg" as="image" /> <link rel="preconnect" href="https://fonts.googleapis.com" /> </head> <body>{children}</body> </html> ); } ``` ### Cumulative Layout Shift (CLS) ```tsx // Prevent CLS with explicit dimensions <Image src="/product.jpg" alt="Product" width={400} height={300} /> // Or use aspect ratio <div className="aspect-video relative"> <Image src="/video-thumb.jpg" alt="Video" fill /> </div> // Skeleton placeholders function ProductCard({ product }: { product?: Product }) { if (!product) { return ( <div className="animate-pulse"> <div className="h-48 bg-gray-200 rounded" /> <div className="h-4 bg-gray-200 rounded mt-2 w-3/4" /> <div className="h-4 bg-gray-200 rounded mt-1 w-1/2" /> </div> ); } return ( <div> <Image src={product.image} alt={product.name} width={300} height={200} /> <h3>{product.name}</h3> <p>{product.price}</p> </div> ); } ``` ### First Input Delay (FID) / Interaction to Next Paint (INP) ```tsx // Defer non-critical JavaScript import Script from 'next/script'; export default function Layout({ children }) { return ( <html> <body> {children} {/* Load analytics after page is interactive */} <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" /> {/* Load chat widget when idle */} <Script src="https://chat.example.com/widget.js" strategy="lazyOnload" /> </body> </html> ); } // Use web workers for heavy computation // app/components/DataProcessor.tsx 'use client'; import { useEffect, useState } from 'react'; function DataProcessor({ data }: { data: number[] }) { const [result, setResult] = useState<number | null>(null); useEffect(() => { const worker = new Worker(new URL('../workers/processor.js', import.meta.url)); worker.postMessage(data); worker.onmessage = (e) => setResult(e.data); return () => worker.terminate(); }, [data]); return <div>Result: {result}</div>; } ``` ### Measuring Performance ```tsx // app/components/PerformanceMonitor.tsx 'use client'; import { useReportWebVitals } from 'next/web-vitals'; export function PerformanceMonitor() { useReportWebVitals((metric) => { switch (metric.name) { case 'LCP': console.log('LCP:', metric.value); break; case 'FID': console.log('FID:', metric.value); break; case 'CLS': console.log('CLS:', metric.value); break; case 'TTFB': console.log('TTFB:', metric.value); break; } // Send to analytics analytics.track('web-vital', { name: metric.name, value: metric.value, id: metric.id, }); }); return null; } ``` --- ## Quick Reference ### Performance Checklist | Area | Optimization | Impact | |------|-------------|--------| | Images | Use next/image with priority for LCP | High | | Fonts | Use next/font with display: swap | Medium | | Code | Dynamic imports for heavy components | High | | Data | Parallel fetching with Promise.all | High | | Render | Server Components by default | High | | Cache | Configure revalidate appropriately | Medium | | Bundle | Tree-shake imports, analyze size | Medium | ### Config Template ```js // next.config.js /** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [{ hostname: 'cdn.example.com' }], formats: ['image/avif', 'image/webp'], }, experimental: { optimizePackageImports: ['lucide-react'], }, headers: async () => [ { source: '/(.*)', headers: [ { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Frame-Options', value: 'DENY' }, ], }, ], }; module.exports = nextConfig; ``` -
react_patterns.md 16.9 KB
# React Patterns Production-ready patterns for building scalable React applications with TypeScript. --- ## Table of Contents - [Component Composition](#component-composition) - [Custom Hooks](#custom-hooks) - [State Management](#state-management) - [Performance Patterns](#performance-patterns) - [Error Boundaries](#error-boundaries) - [Anti-Patterns](#anti-patterns) --- ## Component Composition ### Compound Components Use compound components when building reusable UI components with multiple related parts. ```tsx // Compound component pattern for a Select interface SelectContextType { value: string; onChange: (value: string) => void; } const SelectContext = createContext<SelectContextType | null>(null); function Select({ children, value, onChange }: { children: React.ReactNode; value: string; onChange: (value: string) => void; }) { return ( <SelectContext.Provider value={{ value, onChange }}> <div className="relative">{children}</div> </SelectContext.Provider> ); } function SelectTrigger({ children }: { children: React.ReactNode }) { const context = useContext(SelectContext); if (!context) throw new Error('SelectTrigger must be used within Select'); return ( <button className="flex items-center gap-2 px-4 py-2 border rounded"> {children} </button> ); } function SelectOption({ value, children }: { value: string; children: React.ReactNode }) { const context = useContext(SelectContext); if (!context) throw new Error('SelectOption must be used within Select'); return ( <div onClick={() => context.onChange(value)} className={`px-4 py-2 cursor-pointer hover:bg-gray-100 ${ context.value === value ? 'bg-blue-50' : '' }`} > {children} </div> ); } // Attach sub-components Select.Trigger = SelectTrigger; Select.Option = SelectOption; // Usage <Select value={selected} onChange={setSelected}> <Select.Trigger>Choose option</Select.Trigger> <Select.Option value="a">Option A</Select.Option> <Select.Option value="b">Option B</Select.Option> </Select> ``` ### Render Props Use render props when you need to share behavior with flexible rendering. ```tsx interface MousePosition { x: number; y: number; } function MouseTracker({ render }: { render: (pos: MousePosition) => React.ReactNode }) { const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 }); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { setPosition({ x: e.clientX, y: e.clientY }); }; window.addEventListener('mousemove', handleMouseMove); return () => window.removeEventListener('mousemove', handleMouseMove); }, []); return <>{render(position)}</>; } // Usage <MouseTracker render={({ x, y }) => ( <div>Mouse position: {x}, {y}</div> )} /> ``` ### Higher-Order Components (HOC) Use HOCs for cross-cutting concerns like authentication or logging. ```tsx function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) { return function AuthenticatedComponent(props: P) { const { user, isLoading } = useAuth(); if (isLoading) return <LoadingSpinner />; if (!user) return <Navigate to="/login" />; return <WrappedComponent {...props} />; }; } // Usage const ProtectedDashboard = withAuth(Dashboard); ``` --- ## Custom Hooks ### useAsync - Handle async operations ```tsx interface AsyncState<T> { data: T | null; error: Error | null; status: 'idle' | 'loading' | 'success' | 'error'; } function useAsync<T>(asyncFn: () => Promise<T>, deps: any[] = []) { const [state, setState] = useState<AsyncState<T>>({ data: null, error: null, status: 'idle', }); const execute = useCallback(async () => { setState({ data: null, error: null, status: 'loading' }); try { const data = await asyncFn(); setState({ data, error: null, status: 'success' }); } catch (error) { setState({ data: null, error: error as Error, status: 'error' }); } }, deps); useEffect(() => { execute(); }, [execute]); return { ...state, refetch: execute }; } // Usage function UserProfile({ userId }: { userId: string }) { const { data: user, status, error, refetch } = useAsync( () => fetchUser(userId), [userId] ); if (status === 'loading') return <Spinner />; if (status === 'error') return <Error message={error?.message} />; if (!user) return null; return <Profile user={user} />; } ``` ### useDebounce - Debounce values ```tsx function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // Usage function SearchInput() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); useEffect(() => { if (debouncedQuery) { searchAPI(debouncedQuery); } }, [debouncedQuery]); return <input value={query} onChange={(e) => setQuery(e.target.value)} />; } ``` ### useLocalStorage - Persist state ```tsx function useLocalStorage<T>(key: string, initialValue: T) { const [storedValue, setStoredValue] = useState<T>(() => { if (typeof window === 'undefined') return initialValue; try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } }); const setValue = useCallback((value: T | ((val: T) => T)) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (typeof window !== 'undefined') { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch (error) { console.error('Error saving to localStorage:', error); } }, [key, storedValue]); return [storedValue, setValue] as const; } // Usage const [theme, setTheme] = useLocalStorage('theme', 'light'); ``` ### useMediaQuery - Responsive design ```tsx function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(false); useEffect(() => { const media = window.matchMedia(query); setMatches(media.matches); const listener = (e: MediaQueryListEvent) => setMatches(e.matches); media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, [query]); return matches; } // Usage function ResponsiveNav() { const isMobile = useMediaQuery('(max-width: 768px)'); return isMobile ? <MobileNav /> : <DesktopNav />; } ``` ### usePrevious - Track previous values ```tsx function usePrevious<T>(value: T): T | undefined { const ref = useRef<T>(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } // Usage function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <div> Current: {count}, Previous: {prevCount} </div> ); } ``` --- ## State Management ### Context with Reducer For complex state that multiple components need to access. ```tsx // types.ts interface CartItem { id: string; name: string; price: number; quantity: number; } interface CartState { items: CartItem[]; total: number; } type CartAction = | { type: 'ADD_ITEM'; payload: CartItem } | { type: 'REMOVE_ITEM'; payload: string } | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } } | { type: 'CLEAR_CART' }; // reducer.ts function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case 'ADD_ITEM': { const existingItem = state.items.find(i => i.id === action.payload.id); if (existingItem) { return { ...state, items: state.items.map(item => item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item ), }; } return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }], }; } case 'REMOVE_ITEM': return { ...state, items: state.items.filter(i => i.id !== action.payload), }; case 'UPDATE_QUANTITY': return { ...state, items: state.items.map(item => item.id === action.payload.id ? { ...item, quantity: action.payload.quantity } : item ), }; case 'CLEAR_CART': return { items: [], total: 0 }; default: return state; } } // context.tsx const CartContext = createContext<{ state: CartState; dispatch: React.Dispatch<CartAction>; } | null>(null); function CartProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 }); // Compute total whenever items change const stateWithTotal = useMemo(() => ({ ...state, total: state.items.reduce((sum, item) => sum + item.price * item.quantity, 0), }), [state.items]); return ( <CartContext.Provider value={{ state: stateWithTotal, dispatch }}> {children} </CartContext.Provider> ); } function useCart() { const context = useContext(CartContext); if (!context) throw new Error('useCart must be used within CartProvider'); return context; } ``` ### Zustand (Lightweight Alternative) ```tsx import { create } from 'zustand'; import { persist } from 'zustand/middleware'; interface AuthStore { user: User | null; token: string | null; login: (email: string, password: string) => Promise<void>; logout: () => void; } const useAuthStore = create<AuthStore>()( persist( (set) => ({ user: null, token: null, login: async (email, password) => { const { user, token } = await authAPI.login(email, password); set({ user, token }); }, logout: () => set({ user: null, token: null }), }), { name: 'auth-storage' } ) ); // Usage function Profile() { const { user, logout } = useAuthStore(); return user ? <div>{user.name} <button onClick={logout}>Logout</button></div> : null; } ``` --- ## Performance Patterns ### React.memo with Custom Comparison ```tsx interface ListItemProps { item: { id: string; name: string; count: number }; onSelect: (id: string) => void; } const ListItem = React.memo( function ListItem({ item, onSelect }: ListItemProps) { return ( <div onClick={() => onSelect(item.id)}> {item.name} ({item.count}) </div> ); }, (prevProps, nextProps) => { // Only re-render if item data changed return ( prevProps.item.id === nextProps.item.id && prevProps.item.name === nextProps.item.name && prevProps.item.count === nextProps.item.count ); } ); ``` ### useMemo for Expensive Calculations ```tsx function DataTable({ data, sortColumn, filterText }: { data: Item[]; sortColumn: string; filterText: string; }) { const processedData = useMemo(() => { // Filter let result = data.filter(item => item.name.toLowerCase().includes(filterText.toLowerCase()) ); // Sort result = [...result].sort((a, b) => { const aVal = a[sortColumn as keyof Item]; const bVal = b[sortColumn as keyof Item]; return aVal < bVal ? -1 : aVal > bVal ? 1 : 0; }); return result; }, [data, sortColumn, filterText]); return ( <table> {processedData.map(item => ( <tr key={item.id}>{/* ... */}</tr> ))} </table> ); } ``` ### useCallback for Stable References ```tsx function ParentComponent() { const [items, setItems] = useState<Item[]>([]); // Stable reference - won't cause child re-renders const handleItemClick = useCallback((id: string) => { setItems(prev => prev.map(item => item.id === id ? { ...item, selected: !item.selected } : item )); }, []); const handleAddItem = useCallback((newItem: Item) => { setItems(prev => [...prev, newItem]); }, []); return ( <> <ItemList items={items} onItemClick={handleItemClick} /> <AddItemForm onAdd={handleAddItem} /> </> ); } ``` ### Virtualization for Long Lists ```tsx import { useVirtualizer } from '@tanstack/react-virtual'; function VirtualList({ items }: { items: Item[] }) { const parentRef = useRef<HTMLDivElement>(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, // estimated row height overscan: 5, }); return ( <div ref={parentRef} className="h-[400px] overflow-auto"> <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }} > {virtualizer.getVirtualItems().map(virtualRow => ( <div key={virtualRow.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualRow.size}px`, transform: `translateY(${virtualRow.start}px)`, }} > {items[virtualRow.index].name} </div> ))} </div> </div> ); } ``` --- ## Error Boundaries ### Class-Based Error Boundary ```tsx interface ErrorBoundaryProps { children: React.ReactNode; fallback?: React.ReactNode; onError?: (error: Error, errorInfo: React.ErrorInfo) => void; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { state: ErrorBoundaryState = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { this.props.onError?.(error, errorInfo); // Log to error reporting service console.error('Error caught:', error, errorInfo); } render() { if (this.state.hasError) { return this.props.fallback || ( <div className="p-4 bg-red-50 border border-red-200 rounded"> <h2 className="text-red-800 font-bold">Something went wrong</h2> <p className="text-red-600">{this.state.error?.message}</p> <button onClick={() => this.setState({ hasError: false, error: null })} className="mt-2 px-4 py-2 bg-red-600 text-white rounded" > Try Again </button> </div> ); } return this.props.children; } } // Usage <ErrorBoundary fallback={<ErrorFallback />} onError={(error) => trackError(error)} > <MyComponent /> </ErrorBoundary> ``` ### Suspense with Error Boundary ```tsx function DataComponent() { return ( <ErrorBoundary fallback={<ErrorMessage />}> <Suspense fallback={<LoadingSpinner />}> <AsyncDataLoader /> </Suspense> </ErrorBoundary> ); } ``` --- ## Anti-Patterns ### Avoid: Inline Object/Array Creation in JSX ```tsx // BAD - Creates new object every render, causes re-renders <Component style={{ color: 'red' }} items={[1, 2, 3]} /> // GOOD - Define outside or use useMemo const style = { color: 'red' }; const items = [1, 2, 3]; <Component style={style} items={items} /> // Or with useMemo for dynamic values const style = useMemo(() => ({ color: theme.primary }), [theme.primary]); ``` ### Avoid: Index as Key for Dynamic Lists ```tsx // BAD - Index keys break with reordering/filtering {items.map((item, index) => ( <Item key={index} data={item} /> ))} // GOOD - Use stable unique ID {items.map(item => ( <Item key={item.id} data={item} /> ))} ``` ### Avoid: Prop Drilling ```tsx // BAD - Passing props through many levels <App user={user}> <Layout user={user}> <Sidebar user={user}> <UserInfo user={user} /> </Sidebar> </Layout> </App> // GOOD - Use Context const UserContext = createContext<User | null>(null); function App() { return ( <UserContext.Provider value={user}> <Layout> <Sidebar> <UserInfo /> </Sidebar> </Layout> </UserContext.Provider> ); } function UserInfo() { const user = useContext(UserContext); return <div>{user?.name}</div>; } ``` ### Avoid: Mutating State Directly ```tsx // BAD - Mutates state directly const addItem = (item: Item) => { items.push(item); // WRONG setItems(items); // Won't trigger re-render }; // GOOD - Create new array const addItem = (item: Item) => { setItems(prev => [...prev, item]); }; // GOOD - For objects const updateUser = (field: string, value: string) => { setUser(prev => ({ ...prev, [field]: value })); }; ``` ### Avoid: useEffect for Derived State ```tsx // BAD - Unnecessary effect and extra render const [items, setItems] = useState<Item[]>([]); const [total, setTotal] = useState(0); useEffect(() => { setTotal(items.reduce((sum, item) => sum + item.price, 0)); }, [items]); // GOOD - Compute during render const [items, setItems] = useState<Item[]>([]); const total = items.reduce((sum, item) => sum + item.price, 0); // Or useMemo for expensive calculations const total = useMemo( () => items.reduce((sum, item) => sum + item.price, 0), [items] ); ```
-
-
scripts
-
bundle_analyzer.py 13.5 KB
#!/usr/bin/env python3 """ Frontend Bundle Analyzer Analyzes package.json and project structure for bundle optimization opportunities, heavy dependencies, and best practice recommendations. Usage: python bundle_analyzer.py <project_dir> python bundle_analyzer.py . --json python bundle_analyzer.py /path/to/project --verbose """ import argparse import json import os import re import sys from pathlib import Path def safe_user_path(path_value, base_dir="."): """Resolve a CLI path under the current workspace.""" if base_dir != ".": raise ValueError("Custom base directories are not supported for CLI paths") base_path = Path.cwd().resolve() resolved_path = Path(path_value).expanduser().resolve() try: resolved_path.relative_to(base_path) except ValueError as exc: raise ValueError(f"Path escapes allowed directory: {path_value}") from exc return resolved_path from typing import Dict, List, Optional, Any, Tuple # Known heavy packages and their lighter alternatives HEAVY_PACKAGES = { "moment": { "size": "290KB", "alternative": "date-fns (12KB) or dayjs (2KB)", "reason": "Large locale files bundled by default" }, "lodash": { "size": "71KB", "alternative": "lodash-es with tree-shaking or individual imports (lodash/get)", "reason": "Full library often imported when only few functions needed" }, "jquery": { "size": "87KB", "alternative": "Native DOM APIs or React/Vue patterns", "reason": "Rarely needed in modern frameworks" }, "axios": { "size": "14KB", "alternative": "Native fetch API (0KB) or ky (3KB)", "reason": "Fetch API covers most use cases" }, "underscore": { "size": "17KB", "alternative": "Native ES6+ methods or lodash-es", "reason": "Most utilities now in standard JavaScript" }, "chart.js": { "size": "180KB", "alternative": "recharts (bundled with React) or lightweight-charts", "reason": "Consider if you need all chart types" }, "three": { "size": "600KB", "alternative": "None - use dynamic import for 3D features", "reason": "Very large, should be lazy-loaded" }, "firebase": { "size": "400KB+", "alternative": "Import specific modules (firebase/auth, firebase/firestore)", "reason": "Modular imports significantly reduce size" }, "material-ui": { "size": "Large", "alternative": "shadcn/ui (copy-paste components) or Tailwind", "reason": "Heavy runtime, consider headless alternatives" }, "@mui/material": { "size": "Large", "alternative": "shadcn/ui or Radix UI + Tailwind", "reason": "Heavy runtime, consider headless alternatives" }, "antd": { "size": "Large", "alternative": "shadcn/ui or Radix UI + Tailwind", "reason": "Heavy runtime, consider headless alternatives" } } # Recommended optimizations by package PACKAGE_OPTIMIZATIONS = { "react-icons": "Import individual icons: import { FaHome } from 'react-icons/fa'", "date-fns": "Use tree-shaking: import { format } from 'date-fns'", "@heroicons/react": "Already tree-shakeable, good choice", "lucide-react": "Already tree-shakeable, add to optimizePackageImports in next.config.js", "framer-motion": "Use dynamic import for non-critical animations", "recharts": "Consider lazy loading for dashboard charts", } # Development dependencies that should not be in dependencies DEV_ONLY_PACKAGES = [ "typescript", "@types/", "eslint", "prettier", "jest", "vitest", "@testing-library", "cypress", "playwright", "storybook", "@storybook", "webpack", "vite", "rollup", "esbuild", "tailwindcss", "postcss", "autoprefixer", "sass", "less", "husky", "lint-staged" ] def load_package_json(project_dir: Path) -> Optional[Dict]: """Load and parse package.json.""" package_path = project_dir / "package.json" if not package_path.exists(): return None try: with open(package_path) as f: return json.load(f) except json.JSONDecodeError: return None def analyze_dependencies(package_json: Dict) -> Dict: """Analyze dependencies for issues.""" deps = package_json.get("dependencies", {}) dev_deps = package_json.get("devDependencies", {}) issues = [] warnings = [] optimizations = [] # Check for heavy packages for pkg, info in HEAVY_PACKAGES.items(): if pkg in deps: issues.append({ "package": pkg, "type": "heavy_dependency", "size": info["size"], "alternative": info["alternative"], "reason": info["reason"] }) # Check for dev dependencies in production for pkg in deps.keys(): for dev_pattern in DEV_ONLY_PACKAGES: if dev_pattern in pkg: warnings.append({ "package": pkg, "type": "dev_in_production", "message": f"{pkg} should be in devDependencies, not dependencies" }) # Check for optimization opportunities for pkg in deps.keys(): for opt_pkg, opt_tip in PACKAGE_OPTIMIZATIONS.items(): if opt_pkg in pkg: optimizations.append({ "package": pkg, "tip": opt_tip }) # Check for outdated React patterns if "prop-types" in deps and ("typescript" in dev_deps or "@types/react" in dev_deps): warnings.append({ "package": "prop-types", "type": "redundant", "message": "prop-types is redundant when using TypeScript" }) # Check for multiple state management libraries state_libs = ["redux", "@reduxjs/toolkit", "mobx", "zustand", "jotai", "recoil", "valtio"] found_state_libs = [lib for lib in state_libs if lib in deps] if len(found_state_libs) > 1: warnings.append({ "packages": found_state_libs, "type": "multiple_state_libs", "message": f"Multiple state management libraries found: {', '.join(found_state_libs)}" }) return { "total_dependencies": len(deps), "total_dev_dependencies": len(dev_deps), "issues": issues, "warnings": warnings, "optimizations": optimizations } def check_nextjs_config(project_dir: Path) -> Dict: """Check Next.js configuration for optimizations.""" config_paths = [ project_dir / "next.config.js", project_dir / "next.config.mjs", project_dir / "next.config.ts" ] for config_path in config_paths: if config_path.exists(): try: content = config_path.read_text() suggestions = [] # Check for image optimization if "images" not in content: suggestions.append("Configure images.remotePatterns for optimized image loading") # Check for package optimization if "optimizePackageImports" not in content: suggestions.append("Add experimental.optimizePackageImports for lucide-react, @heroicons/react") # Check for transpilePackages if "transpilePackages" not in content and "swc" not in content: suggestions.append("Consider transpilePackages for monorepo packages") return { "found": True, "path": str(config_path), "suggestions": suggestions } except Exception: pass return { "found": False, "suggestions": ["Create next.config.js with image and bundle optimizations"] } def analyze_imports(project_dir: Path) -> Dict: """Analyze import patterns in source files.""" issues = [] src_dirs = [project_dir / "src", project_dir / "app", project_dir / "pages"] patterns_to_check = [ (r"import\s+\*\s+as\s+\w+\s+from\s+['\"]lodash['\"]", "Avoid import * from lodash, use individual imports"), (r"import\s+moment\s+from\s+['\"]moment['\"]", "Consider replacing moment with date-fns or dayjs"), (r"import\s+\{\s*\w+(?:,\s*\w+){5,}\s*\}\s+from\s+['\"]react-icons", "Import icons from specific icon sets (react-icons/fa)"), ] files_checked = 0 for src_dir in src_dirs: if not src_dir.exists(): continue for ext in ["*.ts", "*.tsx", "*.js", "*.jsx"]: for file_path in src_dir.glob(f"**/{ext}"): if "node_modules" in str(file_path): continue files_checked += 1 try: content = file_path.read_text() for pattern, message in patterns_to_check: if re.search(pattern, content): issues.append({ "file": str(file_path.relative_to(project_dir)), "issue": message }) except Exception: continue return { "files_checked": files_checked, "issues": issues } def calculate_score(analysis: Dict) -> Tuple[int, str]: """Calculate bundle health score.""" score = 100 # Deduct for heavy dependencies score -= len(analysis["dependencies"]["issues"]) * 10 # Deduct for dev deps in production score -= len([w for w in analysis["dependencies"]["warnings"] if w.get("type") == "dev_in_production"]) * 5 # Deduct for import issues score -= len(analysis.get("imports", {}).get("issues", [])) * 3 # Deduct for missing Next.js optimizations if not analysis.get("nextjs", {}).get("found", True): score -= 10 score = max(0, min(100, score)) if score >= 90: grade = "A" elif score >= 80: grade = "B" elif score >= 70: grade = "C" elif score >= 60: grade = "D" else: grade = "F" return score, grade def print_report(analysis: Dict) -> None: """Print human-readable report.""" score, grade = calculate_score(analysis) print("=" * 60) print("FRONTEND BUNDLE ANALYSIS REPORT") print("=" * 60) print(f"\nBundle Health Score: {score}/100 ({grade})") deps = analysis["dependencies"] print(f"\nDependencies: {deps['total_dependencies']} production, {deps['total_dev_dependencies']} dev") # Heavy dependencies if deps["issues"]: print("\n--- HEAVY DEPENDENCIES ---") for issue in deps["issues"]: print(f"\n {issue['package']} ({issue['size']})") print(f" Reason: {issue['reason']}") print(f" Alternative: {issue['alternative']}") # Warnings if deps["warnings"]: print("\n--- WARNINGS ---") for warning in deps["warnings"]: if "package" in warning: print(f" - {warning['package']}: {warning['message']}") else: print(f" - {warning['message']}") # Optimizations if deps["optimizations"]: print("\n--- OPTIMIZATION TIPS ---") for opt in deps["optimizations"]: print(f" - {opt['package']}: {opt['tip']}") # Next.js config if "nextjs" in analysis: nextjs = analysis["nextjs"] if nextjs.get("suggestions"): print("\n--- NEXT.JS CONFIG ---") for suggestion in nextjs["suggestions"]: print(f" - {suggestion}") # Import issues if analysis.get("imports", {}).get("issues"): print("\n--- IMPORT ISSUES ---") for issue in analysis["imports"]["issues"][:10]: # Limit to 10 print(f" - {issue['file']}: {issue['issue']}") # Summary print("\n--- RECOMMENDATIONS ---") if score >= 90: print(" Bundle is well-optimized!") elif deps["issues"]: print(" 1. Replace heavy dependencies with lighter alternatives") if deps["warnings"]: print(" 2. Move dev-only packages to devDependencies") if deps["optimizations"]: print(" 3. Apply import optimizations for tree-shaking") print("\n" + "=" * 60) def main(): parser = argparse.ArgumentParser( description="Analyze frontend project for bundle optimization opportunities" ) parser.add_argument( "project_dir", nargs="?", default=".", help="Project directory to analyze (default: current directory)" ) parser.add_argument( "--json", action="store_true", help="Output in JSON format" ) parser.add_argument( "--verbose", "-v", action="store_true", help="Include detailed import analysis" ) args = parser.parse_args() project_dir = safe_user_path(args.project_dir).resolve() if not project_dir.exists(): print(f"Error: Directory not found: {project_dir}", file=sys.stderr) sys.exit(1) package_json = load_package_json(project_dir) if not package_json: print("Error: No valid package.json found", file=sys.stderr) sys.exit(1) analysis = { "project": str(project_dir), "dependencies": analyze_dependencies(package_json), "nextjs": check_nextjs_config(project_dir) } if args.verbose: analysis["imports"] = analyze_imports(project_dir) analysis["score"], analysis["grade"] = calculate_score(analysis) if args.json: print(json.dumps(analysis, indent=2)) else: print_report(analysis) if __name__ == "__main__": main() -
component_generator.py 10.5 KB
#!/usr/bin/env python3 """ React Component Generator Generates React/Next.js component files with TypeScript, Tailwind CSS, and optional test files following best practices. Usage: python component_generator.py Button --dir src/components/ui python component_generator.py ProductCard --type client --with-test python component_generator.py UserProfile --type server --with-story """ import argparse import os import re import sys from pathlib import Path from datetime import datetime # Component templates TEMPLATES = { "client": '''\'use client\'; import {{ useState }} from 'react'; import {{ cn }} from '@/lib/utils'; interface {name}Props {{ className?: string; children?: React.ReactNode; }} export function {name}({{ className, children }}: {name}Props) {{ return ( <div className={{cn('', className)}}> {{children}} </div> ); }} ''', "server": '''import {{ cn }} from '@/lib/utils'; interface {name}Props {{ className?: string; children?: React.ReactNode; }} export async function {name}({{ className, children }}: {name}Props) {{ return ( <div className={{cn('', className)}}> {{children}} </div> ); }} ''', "hook": '''import {{ useState, useEffect, useCallback }} from 'react'; interface Use{name}Options {{ // Add options here }} interface Use{name}Return {{ // Add return type here isLoading: boolean; error: Error | null; }} export function use{name}(options: Use{name}Options = {{}}): Use{name}Return {{ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); useEffect(() => {{ // Effect logic here }}, []); return {{ isLoading, error, }}; }} ''', "test": '''import {{ render, screen }} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {{ {name} }} from './{name}'; describe('{name}', () => {{ it('renders correctly', () => {{ render(<{name}>Test content</{name}>); expect(screen.getByText('Test content')).toBeInTheDocument(); }}); it('applies custom className', () => {{ render(<{name} className="custom-class">Content</{name}>); expect(screen.getByText('Content').parentElement).toHaveClass('custom-class'); }}); // Add more tests here }}); ''', "story": '''import type {{ Meta, StoryObj }} from '@storybook/react'; import {{ {name} }} from './{name}'; const meta: Meta<typeof {name}> = {{ title: 'Components/{name}', component: {name}, tags: ['autodocs'], argTypes: {{ className: {{ control: 'text', description: 'Additional CSS classes', }}, }}, }}; export default meta; type Story = StoryObj<typeof {name}>; export const Default: Story = {{ args: {{ children: 'Default content', }}, }}; export const WithCustomClass: Story = {{ args: {{ className: 'bg-blue-100 p-4', children: 'Styled content', }}, }}; ''', "index": '''export {{ {name} }} from './{name}'; export type {{ {name}Props }} from './{name}'; ''', } _COMPONENT_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") def _safe_component_name(name: str) -> str: name = name.strip() if not _COMPONENT_NAME_RE.fullmatch(name): raise ValueError("Component name must start with a letter and contain only letters, numbers, hyphens, or underscores") return name def _safe_component_dir(output_dir: Path, pascal_name: str, flat: bool) -> Path: output_root = output_dir.resolve() component_dir = output_root if flat else (output_root / pascal_name).resolve() component_dir.relative_to(output_root) return component_dir def _safe_output_dir(raw_dir: str) -> Path: raw = str(raw_dir).strip() if "\x00" in raw: raise ValueError("Output directory contains an invalid null byte") parts = Path(raw).parts if any(part == ".." for part in parts): raise ValueError("Output directory must not contain '..' segments") return Path(raw).expanduser().resolve() def _safe_component_file(component_dir: Path, filename: str) -> Path: if "/" in filename or "\\" in filename or "\x00" in filename or ".." in filename: raise ValueError(f"Unsafe generated filename: {filename}") target = (component_dir / filename).resolve() target.relative_to(component_dir.resolve()) return target def to_pascal_case(name: str) -> str: """Convert string to PascalCase.""" name = _safe_component_name(name) # Handle kebab-case and snake_case words = name.replace('-', '_').split('_') return ''.join(word.capitalize() for word in words) def to_kebab_case(name: str) -> str: """Convert PascalCase to kebab-case.""" result = [] for i, char in enumerate(name): if char.isupper() and i > 0: result.append('-') result.append(char.lower()) return ''.join(result) def generate_component( name: str, output_dir: Path, component_type: str = "client", with_test: bool = False, with_story: bool = False, with_index: bool = True, flat: bool = False, ) -> dict: """Generate component files.""" pascal_name = to_pascal_case(name) kebab_name = to_kebab_case(pascal_name) # Determine output path component_dir = _safe_component_dir(output_dir, pascal_name, flat) files_created = [] # Create directory component_dir.mkdir(parents=True, exist_ok=True) # Generate main component file if component_type == "hook": main_file = _safe_component_file(component_dir, f"use{pascal_name}.ts") template = TEMPLATES["hook"] else: main_file = _safe_component_file(component_dir, f"{pascal_name}.tsx") template = TEMPLATES[component_type] content = template.format(name=pascal_name) main_file.write_text(content) files_created.append(str(main_file)) # Generate test file if with_test and component_type != "hook": test_file = _safe_component_file(component_dir, f"{pascal_name}.test.tsx") test_content = TEMPLATES["test"].format(name=pascal_name) test_file.write_text(test_content) files_created.append(str(test_file)) # Generate story file if with_story and component_type != "hook": story_file = _safe_component_file(component_dir, f"{pascal_name}.stories.tsx") story_content = TEMPLATES["story"].format(name=pascal_name) story_file.write_text(story_content) files_created.append(str(story_file)) # Generate index file if with_index and not flat: index_file = _safe_component_file(component_dir, "index.ts") index_content = TEMPLATES["index"].format(name=pascal_name) index_file.write_text(index_content) files_created.append(str(index_file)) return { "name": pascal_name, "type": component_type, "directory": str(component_dir), "files": files_created, } def print_result(result: dict, verbose: bool = False) -> None: """Print generation result.""" print(f"\n{'='*50}") print(f"Component Generated: {result['name']}") print(f"{'='*50}") print(f"Type: {result['type']}") print(f"Directory: {result['directory']}") print(f"\nFiles created:") for file in result['files']: print(f" - {file}") print(f"{'='*50}\n") # Print usage hint if result['type'] != 'hook': print("Usage:") print(f" import {{ {result['name']} }} from '@/components/{result['name']}';") print(f"\n <{result['name']}>Content</{result['name']}>") else: print("Usage:") print(f" import {{ use{result['name']} }} from '@/hooks/use{result['name']}';") print(f"\n const {{ isLoading, error }} = use{result['name']}();") def self_test() -> None: assert to_pascal_case("product-card") == "ProductCard" for bad_name in ("../Card", "Bad.Name", "", "1Card"): try: to_pascal_case(bad_name) except ValueError: pass else: raise AssertionError(f"accepted unsafe component name: {bad_name!r}") def main(): parser = argparse.ArgumentParser( description="Generate React/Next.js components with TypeScript and Tailwind CSS" ) parser.add_argument( "name", nargs="?", help="Component name (PascalCase or kebab-case)" ) parser.add_argument( "--self-test", action="store_true", help="Run safety self-checks" ) parser.add_argument( "--dir", "-d", default="src/components", help="Output directory (default: src/components)" ) parser.add_argument( "--type", "-t", choices=["client", "server", "hook"], default="client", help="Component type (default: client)" ) parser.add_argument( "--with-test", action="store_true", help="Generate test file" ) parser.add_argument( "--with-story", action="store_true", help="Generate Storybook story file" ) parser.add_argument( "--no-index", action="store_true", help="Skip generating index.ts file" ) parser.add_argument( "--flat", action="store_true", help="Create files directly in output dir without subdirectory" ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be generated without creating files" ) parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose output" ) args = parser.parse_args() if args.self_test: self_test() return if not args.name: parser.error("name is required unless --self-test is used") output_dir = _safe_output_dir(args.dir) pascal_name = to_pascal_case(args.name) if args.dry_run: print(f"\nDry run - would generate:") print(f" Component: {pascal_name}") print(f" Type: {args.type}") print(f" Directory: {output_dir / pascal_name if not args.flat else output_dir}") print(f" Test: {'Yes' if args.with_test else 'No'}") print(f" Story: {'Yes' if args.with_story else 'No'}") return try: result = generate_component( name=args.name, output_dir=output_dir, component_type=args.type, with_test=args.with_test, with_story=args.with_story, with_index=not args.no_index, flat=args.flat, ) print_result(result, args.verbose) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() -
frontend_scaffolder.py 28.2 KB
#!/usr/bin/env python3 """ Frontend Project Scaffolder Generates a complete Next.js/React project structure with TypeScript, Tailwind CSS, and best practice configurations. Usage: python frontend_scaffolder.py my-app --template nextjs python frontend_scaffolder.py dashboard --template react --features auth,api python frontend_scaffolder.py landing --template nextjs --dry-run """ import argparse import json import re import os import sys from pathlib import Path def safe_user_path(path_value, base_dir="."): """Resolve a CLI path under the current workspace.""" if base_dir != ".": raise ValueError("Custom base directories are not supported for CLI paths") base_path = Path.cwd().resolve() resolved_path = Path(path_value).expanduser().resolve() try: resolved_path.relative_to(base_path) except ValueError as exc: raise ValueError(f"Path escapes allowed directory: {path_value}") from exc return resolved_path from typing import Dict, List, Optional # Project templates TEMPLATES = { "nextjs": { "name": "Next.js 14+ App Router", "description": "Modern Next.js with App Router, Server Components, and TypeScript", "structure": { "app": { "layout.tsx": "ROOT_LAYOUT", "page.tsx": "HOME_PAGE", "globals.css": "GLOBALS_CSS", "(auth)": { "login": {"page.tsx": "AUTH_PAGE"}, "register": {"page.tsx": "AUTH_PAGE"}, }, "api": { "health": {"route.ts": "HEALTH_ROUTE"}, }, }, "components": { "ui": { "button.tsx": "UI_BUTTON", "input.tsx": "UI_INPUT", "card.tsx": "UI_CARD", "index.ts": "UI_INDEX", }, "layout": { "header.tsx": "LAYOUT_HEADER", "footer.tsx": "LAYOUT_FOOTER", "sidebar.tsx": "LAYOUT_SIDEBAR", }, }, "lib": { "utils.ts": "UTILS", "constants.ts": "CONSTANTS", }, "hooks": { "use-debounce.ts": "HOOK_DEBOUNCE", "use-local-storage.ts": "HOOK_LOCAL_STORAGE", }, "types": { "index.ts": "TYPES_INDEX", }, "public": { ".gitkeep": "EMPTY", }, }, "config_files": [ "next.config.js", "tailwind.config.ts", "tsconfig.json", "postcss.config.js", ".eslintrc.json", ".prettierrc", ".gitignore", "package.json", ], }, "react": { "name": "React + Vite", "description": "Modern React with Vite, TypeScript, and Tailwind CSS", "structure": { "src": { "App.tsx": "REACT_APP", "main.tsx": "REACT_MAIN", "index.css": "GLOBALS_CSS", "components": { "ui": { "button.tsx": "UI_BUTTON", "input.tsx": "UI_INPUT", "card.tsx": "UI_CARD", "index.ts": "UI_INDEX", }, }, "hooks": { "use-debounce.ts": "HOOK_DEBOUNCE", "use-local-storage.ts": "HOOK_LOCAL_STORAGE", }, "lib": { "utils.ts": "UTILS", }, "types": { "index.ts": "TYPES_INDEX", }, }, "public": { ".gitkeep": "EMPTY", }, }, "config_files": [ "vite.config.ts", "tailwind.config.ts", "tsconfig.json", "postcss.config.js", ".eslintrc.json", ".prettierrc", ".gitignore", "package.json", "index.html", ], }, } # Feature modules that can be added FEATURES = { "auth": { "description": "Authentication with session management", "files": { "lib/auth.ts": "AUTH_LIB", "middleware.ts": "AUTH_MIDDLEWARE", "components/auth/login-form.tsx": "LOGIN_FORM", "components/auth/register-form.tsx": "REGISTER_FORM", }, "dependencies": ["next-auth", "@auth/core"], }, "api": { "description": "API client with React Query", "files": { "lib/api-client.ts": "API_CLIENT", "lib/query-client.ts": "QUERY_CLIENT", "providers/query-provider.tsx": "QUERY_PROVIDER", }, "dependencies": ["@tanstack/react-query", "axios"], }, "forms": { "description": "Form handling with React Hook Form + Zod", "files": { "lib/form-utils.ts": "FORM_UTILS", "components/forms/form-field.tsx": "FORM_FIELD", }, "dependencies": ["react-hook-form", "@hookform/resolvers", "zod"], }, "testing": { "description": "Testing setup with Vitest and Testing Library", "files": { "vitest.config.ts": "VITEST_CONFIG", "src/test/setup.ts": "TEST_SETUP", "src/test/utils.tsx": "TEST_UTILS", }, "dependencies": ["vitest", "@testing-library/react", "@testing-library/jest-dom"], }, "storybook": { "description": "Component documentation with Storybook", "files": { ".storybook/main.ts": "STORYBOOK_MAIN", ".storybook/preview.ts": "STORYBOOK_PREVIEW", }, "dependencies": ["@storybook/react-vite", "@storybook/addon-essentials"], }, } # File content templates FILE_CONTENTS = { "ROOT_LAYOUT": '''import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export const metadata: Metadata = { title: 'My App', description: 'Built with Next.js', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body className={`${inter.variable} font-sans antialiased`}> {children} </body> </html> ); } ''', "HOME_PAGE": '''export default function Home() { return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-4xl font-bold">Welcome</h1> <p className="mt-4 text-lg text-gray-600"> Get started by editing app/page.tsx </p> </main> ); } ''', "GLOBALS_CSS": '''@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ''', "UI_BUTTON": '''import { forwardRef } from 'react'; import { cn } from '@/lib/utils'; interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'default' | 'destructive' | 'outline' | 'ghost'; size?: 'default' | 'sm' | 'lg'; } const Button = forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant = 'default', size = 'default', ...props }, ref) => { return ( <button className={cn( 'inline-flex items-center justify-center rounded-md font-medium transition-colors', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'disabled:pointer-events-none disabled:opacity-50', { 'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default', 'bg-destructive text-destructive-foreground hover:bg-destructive/90': variant === 'destructive', 'border border-input bg-background hover:bg-accent': variant === 'outline', 'hover:bg-accent hover:text-accent-foreground': variant === 'ghost', }, { 'h-10 px-4 py-2': size === 'default', 'h-9 px-3': size === 'sm', 'h-11 px-8': size === 'lg', }, className )} ref={ref} {...props} /> ); } ); Button.displayName = 'Button'; export { Button, type ButtonProps }; ''', "UI_INPUT": '''import { forwardRef } from 'react'; import { cn } from '@/lib/utils'; interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { error?: string; } const Input = forwardRef<HTMLInputElement, InputProps>( ({ className, error, ...props }, ref) => { return ( <div className="w-full"> <input className={cn( 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2', 'text-sm ring-offset-background file:border-0 file:bg-transparent', 'file:text-sm file:font-medium placeholder:text-muted-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'disabled:cursor-not-allowed disabled:opacity-50', error && 'border-destructive focus-visible:ring-destructive', className )} ref={ref} {...props} /> {error && <p className="mt-1 text-sm text-destructive">{error}</p>} </div> ); } ); Input.displayName = 'Input'; export { Input, type InputProps }; ''', "UI_CARD": '''import { cn } from '@/lib/utils'; interface CardProps extends React.HTMLAttributes<HTMLDivElement> {} function Card({ className, ...props }: CardProps) { return ( <div className={cn( 'rounded-lg border bg-card text-card-foreground shadow-sm', className )} {...props} /> ); } function CardHeader({ className, ...props }: CardProps) { return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />; } function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) { return <h3 className={cn('text-2xl font-semibold leading-none', className)} {...props} />; } function CardContent({ className, ...props }: CardProps) { return <div className={cn('p-6 pt-0', className)} {...props} />; } function CardFooter({ className, ...props }: CardProps) { return <div className={cn('flex items-center p-6 pt-0', className)} {...props} />; } export { Card, CardHeader, CardTitle, CardContent, CardFooter }; ''', "UI_INDEX": '''export { Button } from './button'; export { Input } from './input'; export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './card'; ''', "UTILS": '''import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export function formatDate(date: Date | string): string { return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', }).format(new Date(date)); } export function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } ''', "CONSTANTS": '''export const APP_NAME = 'My App'; export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; export const ROUTES = { home: '/', login: '/login', register: '/register', dashboard: '/dashboard', } as const; export const QUERY_KEYS = { user: ['user'], products: ['products'], } as const; ''', "HOOK_DEBOUNCE": '''import { useState, useEffect } from 'react'; export function useDebounce<T>(value: T, delay: number = 500): T { const [debouncedValue, setDebouncedValue] = useState<T>(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } ''', "HOOK_LOCAL_STORAGE": '''import { useState, useEffect } from 'react'; export function useLocalStorage<T>( key: string, initialValue: T ): [T, (value: T | ((prev: T) => T)) => void] { const [storedValue, setStoredValue] = useState<T>(() => { if (typeof window === 'undefined') return initialValue; try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } }); useEffect(() => { if (typeof window !== 'undefined') { window.localStorage.setItem(key, JSON.stringify(storedValue)); } }, [key, storedValue]); return [storedValue, setStoredValue]; } ''', "TYPES_INDEX": '''export interface User { id: string; email: string; name: string; createdAt: Date; } export interface ApiResponse<T> { data: T; message?: string; error?: string; } export interface PaginatedResponse<T> { data: T[]; total: number; page: number; pageSize: number; totalPages: number; } ''', "HEALTH_ROUTE": '''import { NextResponse } from 'next/server'; export async function GET() { return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString(), }); } ''', "AUTH_PAGE": ''''use client'; export default function AuthPage() { return ( <div className="flex min-h-screen items-center justify-center"> <div className="w-full max-w-md p-8"> <h1 className="text-2xl font-bold text-center">Authentication</h1> </div> </div> ); } ''', "LAYOUT_HEADER": '''import Link from 'next/link'; export function Header() { return ( <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur"> <div className="container flex h-14 items-center"> <Link href="/" className="font-bold"> Logo </Link> <nav className="ml-auto flex gap-4"> <Link href="/about" className="text-sm text-muted-foreground hover:text-foreground"> About </Link> </nav> </div> </header> ); } ''', "LAYOUT_FOOTER": '''export function Footer() { return ( <footer className="border-t py-6"> <div className="container text-center text-sm text-muted-foreground"> <p>© {new Date().getFullYear()} My App. All rights reserved.</p> </div> </footer> ); } ''', "LAYOUT_SIDEBAR": '''interface SidebarProps { children?: React.ReactNode; } export function Sidebar({ children }: SidebarProps) { return ( <aside className="fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-64 border-r bg-background"> <div className="p-4">{children}</div> </aside> ); } ''', "REACT_APP": '''import { Button } from './components/ui'; function App() { return ( <main className="flex min-h-screen flex-col items-center justify-center p-24"> <h1 className="text-4xl font-bold">Welcome</h1> <p className="mt-4 text-lg text-gray-600"> Get started by editing src/App.tsx </p> <Button className="mt-6">Get Started</Button> </main> ); } export default App; ''', "REACT_MAIN": '''import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <App /> </React.StrictMode> ); ''', "EMPTY": "", } def generate_structure( base_path: Path, structure: Dict, dry_run: bool = False ) -> List[str]: """Generate directory structure recursively.""" created_files = [] for name, content in structure.items(): current_path = base_path / name if isinstance(content, dict): # It's a directory if not dry_run: current_path.mkdir(parents=True, exist_ok=True) created_files.extend(generate_structure(current_path, content, dry_run)) else: # It's a file if not dry_run: current_path.parent.mkdir(parents=True, exist_ok=True) file_content = FILE_CONTENTS.get(content, "") current_path.write_text(file_content) created_files.append(str(current_path)) return created_files def generate_config_files( project_path: Path, template: str, project_name: str, features: List[str], dry_run: bool = False ) -> List[str]: """Generate configuration files.""" created_files = [] config_templates = get_config_templates(project_name, template, features) template_config = TEMPLATES[template] for config_file in template_config["config_files"]: file_path = project_path / config_file if config_file in config_templates: if not dry_run: file_path.write_text(config_templates[config_file]) created_files.append(str(file_path)) return created_files def get_config_templates(name: str, template: str, features: List[str]) -> Dict[str, str]: """Get configuration file contents.""" deps = { "nextjs": { "dependencies": { "next": "^14.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "clsx": "^2.0.0", "tailwind-merge": "^2.0.0", }, "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "autoprefixer": "^10.0.0", "eslint": "^8.0.0", "eslint-config-next": "^14.0.0", "postcss": "^8.0.0", "prettier": "^3.0.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", }, }, "react": { "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "clsx": "^2.0.0", "tailwind-merge": "^2.0.0", }, "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.0.0", "eslint": "^8.0.0", "postcss": "^8.0.0", "prettier": "^3.0.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", "vite": "^5.0.0", }, }, } # Add feature dependencies for feature in features: if feature in FEATURES: for dep in FEATURES[feature].get("dependencies", []): deps[template]["dependencies"][dep] = "latest" package_json = { "name": name, "version": "0.1.0", "private": True, "scripts": { "dev": "next dev" if template == "nextjs" else "vite", "build": "next build" if template == "nextjs" else "vite build", "start": "next start" if template == "nextjs" else "vite preview", "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write .", }, "dependencies": deps[template]["dependencies"], "devDependencies": deps[template]["devDependencies"], } return { "package.json": json.dumps(package_json, indent=2), "tsconfig.json": '''{ "compilerOptions": { "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ''', "tailwind.config.ts": '''import type { Config } from 'tailwindcss'; const config: Config = { content: [ './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', './src/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { colors: { background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))', }, secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))', }, destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))', }, muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))', }, accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))', }, border: 'hsl(var(--border))', ring: 'hsl(var(--ring))', }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', }, }, }, plugins: [], }; export default config; ''', "postcss.config.js": '''module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ''', "next.config.js": '''/** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [], formats: ['image/avif', 'image/webp'], }, experimental: { optimizePackageImports: ['lucide-react'], }, }; module.exports = nextConfig; ''', "vite.config.ts": '''import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, }); ''', ".eslintrc.json": '''{ "extends": ["next/core-web-vitals", "prettier"], "rules": { "react/no-unescaped-entities": "off" } } ''', ".prettierrc": '''{ "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "printWidth": 100 } ''', ".gitignore": '''# Dependencies node_modules/ .pnp .pnp.js # Build .next/ out/ dist/ build/ # Environment .env .env.local .env.*.local # IDE .vscode/ .idea/ # Debug npm-debug.log* yarn-debug.log* yarn-error.log* # OS .DS_Store Thumbs.db # Testing coverage/ ''', "index.html": '''<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>''' + name + '''</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html> ''', } def scaffold_project( name: str, output_dir: Path, template: str = "nextjs", features: Optional[List[str]] = None, dry_run: bool = False, ) -> Dict: """Scaffold a complete frontend project.""" features = features or [] if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name) > 64: return {"error": "Project name must be 1-64 lowercase letters, digits, or hyphen-separated words"} output_dir = output_dir.resolve() if output_dir.is_symlink(): return {"error": "Output directory must not be a symbolic link"} project_path = output_dir / name if project_path.exists() and not dry_run: return {"error": f"Directory already exists: {project_path}"} template_config = TEMPLATES.get(template) if not template_config: return {"error": f"Unknown template: {template}"} created_files = [] # Create project directory if not dry_run: project_path.mkdir(parents=True, exist_ok=True) # Generate base structure created_files.extend( generate_structure(project_path, template_config["structure"], dry_run) ) # Generate config files created_files.extend( generate_config_files(project_path, template, name, features, dry_run) ) # Add feature files for feature in features: if feature in FEATURES: for file_path, content_key in FEATURES[feature]["files"].items(): full_path = project_path / file_path if not dry_run: full_path.parent.mkdir(parents=True, exist_ok=True) content = FILE_CONTENTS.get(content_key, f"// TODO: Implement {content_key}") full_path.write_text(content) created_files.append(str(full_path)) return { "name": name, "template": template, "template_name": template_config["name"], "features": features, "path": str(project_path), "files_created": len(created_files), "files": created_files, "next_steps": [ f"cd {name}", "npm install", "npm run dev", ], } def print_result(result: Dict) -> None: """Print scaffolding result.""" if "error" in result: print(f"Error: {result['error']}", file=sys.stderr) return print(f"\n{'='*60}") print(f"Project Scaffolded: {result['name']}") print(f"{'='*60}") print(f"Template: {result['template_name']}") print(f"Location: {result['path']}") print(f"Files Created: {result['files_created']}") if result["features"]: print(f"Features: {', '.join(result['features'])}") print(f"\nNext Steps:") for step in result["next_steps"]: print(f" $ {step}") print(f"{'='*60}\n") def main(): parser = argparse.ArgumentParser( description="Scaffold a frontend project with best practices" ) parser.add_argument( "name", help="Project name (kebab-case recommended)" ) parser.add_argument( "--dir", "-d", default=".", help="Output directory (default: current directory)" ) parser.add_argument( "--template", "-t", choices=list(TEMPLATES.keys()), default="nextjs", help="Project template (default: nextjs)" ) parser.add_argument( "--features", "-f", help="Comma-separated features to add (auth,api,forms,testing,storybook)" ) parser.add_argument( "--list-templates", action="store_true", help="List available templates" ) parser.add_argument( "--list-features", action="store_true", help="List available features" ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be created without creating files" ) parser.add_argument( "--json", action="store_true", help="Output in JSON format" ) args = parser.parse_args() if args.list_templates: print("\nAvailable Templates:") for key, template in TEMPLATES.items(): print(f" {key}: {template['name']}") print(f" {template['description']}") return if args.list_features: print("\nAvailable Features:") for key, feature in FEATURES.items(): print(f" {key}: {feature['description']}") deps = ", ".join(feature.get("dependencies", [])) if deps: print(f" Adds: {deps}") return features = [] if args.features: features = [f.strip() for f in args.features.split(",")] invalid = [f for f in features if f not in FEATURES] if invalid: print(f"Unknown features: {', '.join(invalid)}", file=sys.stderr) print(f"Valid features: {', '.join(FEATURES.keys())}") sys.exit(1) result = scaffold_project( name=args.name, output_dir=safe_user_path(args.dir), template=args.template, features=features, dry_run=args.dry_run, ) if args.json: print(json.dumps(result, indent=2)) else: print_result(result) if __name__ == "__main__": main()
-
-
SKILL.md 12.2 KB
--- name: senior-frontend description: Frontend development skill for React, Next.js, TypeScript, and Tailwind CSS applications. Use when building React components, optimizing Next.js performance, analyzing bundle sizes, scaffolding frontend projects, implementing accessibility, or reviewing frontend code quality. risk: safe source: https://github.com/alirezarezvani/claude-skills date_added: "2026-03-07" --- # Senior Frontend Frontend development patterns, performance optimization, and automation tools for React/Next.js applications. ## When to Use - Use when scaffolding a new React or Next.js project with TypeScript and Tailwind CSS. - Use when generating new components or custom hooks. - Use when analyzing and optimizing bundle sizes for frontend applications. - Use to implement or review advanced React patterns like Compound Components or Render Props. - Use to ensure accessibility compliance and implement robust testing strategies. ## Table of Contents - [Project Scaffolding](#project-scaffolding) - [Component Generation](#component-generation) - [Bundle Analysis](#bundle-analysis) - [React Patterns](#react-patterns) - [Next.js Optimization](#nextjs-optimization) - [Accessibility and Testing](#accessibility-and-testing) --- ## Project Scaffolding Generate a new Next.js or React project with TypeScript, Tailwind CSS, and best practice configurations. ### Workflow: Create New Frontend Project 1. Run the scaffolder with your project name and template: ```bash python scripts/frontend_scaffolder.py my-app --template nextjs ``` 2. Add optional features (auth, api, forms, testing, storybook): ```bash python scripts/frontend_scaffolder.py dashboard --template nextjs --features auth,api ``` 3. Navigate to the project and install dependencies: ```bash cd my-app && npm install ``` 4. Start the development server: ```bash npm run dev ``` ### Scaffolder Options | Option | Description | | -------------------- | ------------------------------------------------- | | `--template nextjs` | Next.js 14+ with App Router and Server Components | | `--template react` | React + Vite with TypeScript | | `--features auth` | Add NextAuth.js authentication | | `--features api` | Add React Query + API client | | `--features forms` | Add React Hook Form + Zod validation | | `--features testing` | Add Vitest + Testing Library | | `--dry-run` | Preview files without creating them | ### Generated Structure (Next.js) ``` my-app/ ├── app/ │ ├── layout.tsx # Root layout with fonts │ ├── page.tsx # Home page │ ├── globals.css # Tailwind + CSS variables │ └── api/health/route.ts ├── components/ │ ├── ui/ # Button, Input, Card │ └── layout/ # Header, Footer, Sidebar ├── hooks/ # useDebounce, useLocalStorage ├── lib/ # utils (cn), constants ├── types/ # TypeScript interfaces ├── tailwind.config.ts ├── next.config.js └── package.json ``` --- ## Component Generation Generate React components with TypeScript, tests, and Storybook stories. ### Workflow: Create a New Component 1. Generate a client component: ```bash python scripts/component_generator.py Button --dir src/components/ui ``` 2. Generate a server component: ```bash python scripts/component_generator.py ProductCard --type server ``` 3. Generate with test and story files: ```bash python scripts/component_generator.py UserProfile --with-test --with-story ``` 4. Generate a custom hook: ```bash python scripts/component_generator.py FormValidation --type hook ``` ### Generator Options | Option | Description | | --------------- | -------------------------------------------- | | `--type client` | Client component with 'use client' (default) | | `--type server` | Async server component | | `--type hook` | Custom React hook | | `--with-test` | Include test file | | `--with-story` | Include Storybook story | | `--flat` | Create in output dir without subdirectory | | `--dry-run` | Preview without creating files | ### Generated Component Example ```tsx "use client"; import { useState } from "react"; import { cn } from "@/lib/utils"; interface ButtonProps { className?: string; children?: React.ReactNode; } export function Button({ className, children }: ButtonProps) { return <div className={cn("", className)}>{children}</div>; } ``` --- ## Bundle Analysis Analyze package.json and project structure for bundle optimization opportunities. ### Workflow: Optimize Bundle Size 1. Run the analyzer on your project: ```bash python scripts/bundle_analyzer.py /path/to/project ``` 2. Review the health score and issues: ``` Bundle Health Score: 75/100 (C) HEAVY DEPENDENCIES: moment (290KB) Alternative: date-fns (12KB) or dayjs (2KB) lodash (71KB) Alternative: lodash-es with tree-shaking ``` 3. Apply the recommended fixes by replacing heavy dependencies. 4. Re-run with verbose mode to check import patterns: ```bash python scripts/bundle_analyzer.py . --verbose ``` ### Bundle Score Interpretation | Score | Grade | Action | | ------ | ----- | ------------------------------ | | 90-100 | A | Bundle is well-optimized | | 80-89 | B | Minor optimizations available | | 70-79 | C | Replace heavy dependencies | | 60-69 | D | Multiple issues need attention | | 0-59 | F | Critical bundle size problems | ### Heavy Dependencies Detected The analyzer identifies these common heavy packages: | Package | Size | Alternative | | ------------- | ----- | ------------------------------ | | moment | 290KB | date-fns (12KB) or dayjs (2KB) | | lodash | 71KB | lodash-es with tree-shaking | | axios | 14KB | Native fetch or ky (3KB) | | jquery | 87KB | Native DOM APIs | | @mui/material | Large | shadcn/ui or Radix UI | --- ## React Patterns Reference: `references/react_patterns.md` ### Compound Components Share state between related components: ```tsx const Tabs = ({ children }) => { const [active, setActive] = useState(0); return ( <TabsContext.Provider value={{ active, setActive }}> {children} </TabsContext.Provider> ); }; Tabs.List = TabList; Tabs.Panel = TabPanel; // Usage <Tabs> <Tabs.List> <Tabs.Tab>One</Tabs.Tab> <Tabs.Tab>Two</Tabs.Tab> </Tabs.List> <Tabs.Panel>Content 1</Tabs.Panel> <Tabs.Panel>Content 2</Tabs.Panel> </Tabs>; ``` ### Custom Hooks Extract reusable logic: ```tsx function useDebounce<T>(value: T, delay = 500): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // Usage const debouncedSearch = useDebounce(searchTerm, 300); ``` ### Render Props Share rendering logic: ```tsx function DataFetcher({ url, render }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { fetch(url) .then((r) => r.json()) .then(setData) .finally(() => setLoading(false)); }, [url]); return render({ data, loading }); } // Usage <DataFetcher url="/api/users" render={({ data, loading }) => loading ? <Spinner /> : <UserList users={data} /> } />; ``` --- ## Next.js Optimization Reference: `references/nextjs_optimization_guide.md` ### Server vs Client Components Use Server Components by default. Add 'use client' only when you need: - Event handlers (onClick, onChange) - State (useState, useReducer) - Effects (useEffect) - Browser APIs ```tsx // Server Component (default) - no 'use client' async function ProductPage({ params }) { const product = await getProduct(params.id); // Server-side fetch return ( <div> <h1>{product.name}</h1> <AddToCartButton productId={product.id} /> {/* Client component */} </div> ); } // Client Component ("use client"); function AddToCartButton({ productId }) { const [adding, setAdding] = useState(false); return <button onClick={() => addToCart(productId)}>Add</button>; } ``` ### Image Optimization ```tsx import Image from 'next/image'; // Above the fold - load immediately <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority /> // Responsive image with fill <div className="relative aspect-video"> <Image src="/product.jpg" alt="Product" fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" /> </div> ``` ### Data Fetching Patterns ```tsx // Parallel fetching async function Dashboard() { const [user, stats] = await Promise.all([getUser(), getStats()]); return <div>...</div>; } // Streaming with Suspense async function ProductPage({ params }) { return ( <div> <ProductDetails id={params.id} /> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={params.id} /> </Suspense> </div> ); } ``` --- ## Accessibility and Testing Reference: `references/frontend_best_practices.md` ### Accessibility Checklist 1. **Semantic HTML**: Use proper elements (`<button>`, `<nav>`, `<main>`) 2. **Keyboard Navigation**: All interactive elements focusable 3. **ARIA Labels**: Provide labels for icons and complex widgets 4. **Color Contrast**: Minimum 4.5:1 for normal text 5. **Focus Indicators**: Visible focus states ```tsx // Accessible button <button type="button" aria-label="Close dialog" onClick={onClose} className="focus-visible:ring-2 focus-visible:ring-blue-500" > <XIcon aria-hidden="true" /> </button> // Skip link for keyboard users <a href="#main-content" className="sr-only focus:not-sr-only"> Skip to main content </a> ``` ### Testing Strategy ```tsx // Component test with React Testing Library import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; test("button triggers action on click", async () => { const onClick = vi.fn(); render(<Button onClick={onClick}>Click me</Button>); await userEvent.click(screen.getByRole("button")); expect(onClick).toHaveBeenCalledTimes(1); }); // Test accessibility test("dialog is accessible", async () => { render(<Dialog open={true} title="Confirm" />); expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("dialog")).toHaveAttribute("aria-labelledby"); }); ``` --- ## Quick Reference ### Common Next.js Config ```js // next.config.js const nextConfig = { images: { remotePatterns: [{ hostname: "cdn.example.com" }], formats: ["image/avif", "image/webp"], }, experimental: { optimizePackageImports: ["lucide-react", "@heroicons/react"], }, }; ``` ### Tailwind CSS Utilities ```tsx // Conditional classes with cn() import { cn } from "@/lib/utils"; <button className={cn( "px-4 py-2 rounded", variant === "primary" && "bg-blue-500 text-white", disabled && "opacity-50 cursor-not-allowed", )} />; ``` ### TypeScript Patterns ```tsx // Props with children interface CardProps { className?: string; children: React.ReactNode; } // Generic component interface ListProps<T> { items: T[]; renderItem: (item: T) => React.ReactNode; } function List<T>({ items, renderItem }: ListProps<T>) { return <ul>{items.map(renderItem)}</ul>; } ``` --- ## Resources - React Patterns: `references/react_patterns.md` - Next.js Optimization: `references/nextjs_optimization_guide.md` - Best Practices: `references/frontend_best_practices.md` ## Limitations - Use this skill only when the task clearly matches the scope described above. - Do not treat the output as a substitute for environment-specific validation, testing, or expert review. - Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.