Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ jobs:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_PUBLIC_KEY: ${{ secrets.SUPABASE_PUBLIC_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
CF_CAPTCHA_SITE_KEY: ${{ secrets.CF_CAPTCHA_SITE_KEY }}
CF_CAPTCHA_SECRET_KEY: ${{ secrets.CF_CAPTCHA_SECRET_KEY }}
run: npm run build # This should create the dist/ directory

- name: Deploy to Cloudflare Pages
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ jobs:
echo "E2E_USERNAME_ID=${{ secrets.E2E_USERNAME_ID }}" >> .env.integration
echo "E2E_USERNAME=${{ secrets.E2E_USERNAME }}" >> .env.integration
echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" >> .env.integration
echo "CF_CAPTCHA_SITE_KEY=${{ secrets.CF_CAPTCHA_SITE_KEY }}" >> .env.integration
echo "CF_CAPTCHA_SECRET_KEY=${{ secrets.CF_CAPTCHA_SECRET_KEY }}" >> .env.integration

- name: Run E2E tests
run: npm run test:e2e
Expand Down
99 changes: 99 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Common Development Commands

### Development Server
- `npm run dev` - Start development server on port 3000 (local mode)
- `npm run dev:e2e` - Start development server in integration mode for E2E testing

### Building and Deployment
- `npm run build` - Build the Astro application for production
- `npm run preview` - Preview the built application locally

### Code Quality
- `npm run lint` - Lint and fix TypeScript/Astro files
- `npm run lint:check` - Check linting without fixing
- `npm run format` - Format code with Prettier
- `npm run format:check` - Check formatting without fixing

### Testing
- `npm run test` - Run unit tests with Vitest
- `npm run test:watch` - Run tests in watch mode
- `npm run test:ui` - Run tests with UI interface
- `npm run test:coverage` - Generate test coverage report
- `npm run test:e2e` - Run end-to-end tests with Playwright
- `npm run test:e2e:ui` - Run E2E tests with UI
- `npm run test:e2e:codegen` - Generate test code with Playwright

### Special Scripts
- `npm run generate-rules` - Generate rules JSON from TypeScript definitions

## Architecture Overview

### Technology Stack
- **Framework**: Astro 5 with React 18.3 integration
- **Styling**: Tailwind CSS 4
- **State Management**: Zustand for client-side state
- **Database**: Supabase (PostgreSQL with real-time features)
- **Testing**: Vitest for unit tests, Playwright for E2E tests
- **Authentication**: Supabase Auth with email/password and password reset

### Project Structure

#### Core Application (`src/`)
- `pages/` - Astro pages with API routes under `api/`
- `components/` - React components organized by feature
- `data/` - Static data including AI rules definitions in `rules/` subdirectory
- `services/` - Business logic services, notably `RulesBuilderService`
- `store/` - Zustand stores for state management
- `hooks/` - Custom React hooks

#### Key Components Architecture
- **Rules System**: Rules are organized by technology stacks (frontend, backend, database, etc.) and stored in `src/data/rules/`
- **Rules Builder Service**: Core service in `src/services/rules-builder/` that generates markdown content using strategy pattern (single-file vs multi-file output)
- **Collections System**: User can save and manage rule collections via `collectionsStore`
- **Feature Flags**: Environment-based feature toggling system in `src/features/featureFlags.ts`

#### MCP Server (`mcp-server/`)
Standalone Cloudflare Worker implementing Model Context Protocol for programmatic access to AI rules. Provides tools:
- `listAvailableRules` - Get available rule categories
- `getRuleContent` - Fetch specific rule content

### State Management Pattern
The application uses Zustand with multiple specialized stores:
- `techStackStore` - Manages selected libraries and tech stack
- `collectionsStore` - Handles saved rule collections with dirty state tracking
- `authStore` - Authentication state management
- `projectStore` - Project metadata (name, description)

### Environment Configuration
- Uses Astro's environment schema for type-safe environment variables
- Supports three environments: `local`, `integration`, `prod`
- Feature flags control functionality per environment
- Requires `.env.local` with Supabase credentials and Cloudflare Turnstile keys

### Database Integration
- Supabase integration with TypeScript types in `src/db/database.types.ts`
- Collections are stored in Supabase with user association
- Real-time capabilities available but not currently utilized

### Testing Strategy
- Unit tests use Vitest with React Testing Library and JSDOM
- E2E tests use Playwright with Page Object Model pattern
- Test files located in `tests/` for unit tests and `e2e/` for E2E tests
- All tests run in CI/CD pipeline

### Rules Content System
Rules are defined as TypeScript objects and exported from category-specific files in `src/data/rules/`. The system supports:
- Categorization by technology layers (frontend, backend, database, etc.)
- Library-specific rules with placeholder replacement
- Multi-file vs single-file output strategies
- Markdown generation with project context

### Development Workflow
1. Rules contributions go in `src/data/rules/` with corresponding translations in `src/i18n/translations.ts`
2. Use feature flags to control new functionality rollout
3. Collections allow users to save and share rule combinations
4. The MCP server enables programmatic access for AI assistants
137 changes: 96 additions & 41 deletions src/components/rule-preview/RulePreviewTopbar.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,111 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check } from 'lucide-react';
import { useProjectStore } from '../../store/projectStore';
import { RulesPath } from './RulesPath';
import { RulesPreviewActions } from './RulesPreviewActions';
import type { RulesContent } from '../../services/rules-builder/RulesBuilderTypes.ts';
import { type AIEnvironment, AIEnvironmentName } from '../../data/ai-environments.ts';
import {
type AIEnvironment,
AIEnvironmentName,
aiEnvironmentConfig,
} from '../../data/ai-environments.ts';
import RulesPreviewCopyDownloadActions from './RulesPreviewCopyDownloadActions.tsx';

interface RulePreviewTopbarProps {
rulesContent: RulesContent[];
}

interface EnvButtonProps {
environment: AIEnvironment;
interface EnvironmentDropdownProps {
selectedEnvironment: AIEnvironment;
isMultiFileEnvironment: boolean;
onSetSelectedEnvironment: (environment: AIEnvironment) => void;
}

const EnvButton: React.FC<EnvButtonProps> = ({
environment,
const EnvironmentDropdown: React.FC<EnvironmentDropdownProps> = ({
selectedEnvironment,
onSetSelectedEnvironment,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

// Handle keyboard navigation
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setIsOpen(!isOpen);
}
};

const handleOptionSelect = (environment: AIEnvironment) => {
onSetSelectedEnvironment(environment);
setIsOpen(false);
};

const selectedConfig = aiEnvironmentConfig[selectedEnvironment];

return (
<button
onClick={() => onSetSelectedEnvironment(environment)}
className={`px-3 py-1 text-xs rounded-md ${
selectedEnvironment === environment
? 'bg-indigo-700 hover:bg-indigo-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{`${environment[0].toUpperCase()}${environment.slice(1)}`}
</button>
<div className="relative" ref={dropdownRef}>
{/* Dropdown trigger button */}
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className="flex items-center justify-between w-full sm:w-auto min-w-[180px] px-3 py-2 text-sm bg-gray-700 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-800"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label="Select AI environment"
>
<span className="truncate">{selectedConfig?.displayName || 'Select Environment'}</span>
<ChevronDown
className={`ml-2 h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>

{/* Dropdown menu */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 w-full sm:w-64 bg-gray-800 border border-gray-600 rounded-md shadow-lg z-50 max-h-64 overflow-y-auto">
<ul role="listbox" className="py-1">
{Object.values(AIEnvironmentName).map((environment) => {
const config = aiEnvironmentConfig[environment];
if (!config) return null;

const isSelected = selectedEnvironment === environment;

return (
<li key={environment} role="option" aria-selected={isSelected}>
<button
onClick={() => handleOptionSelect(environment)}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-700 focus:outline-none focus:bg-gray-700 ${
isSelected ? 'bg-gray-700 text-white' : 'text-gray-300'
}`}
>
<span className="truncate">{config.displayName}</span>
{isSelected && <Check className="h-4 w-4 text-indigo-400 ml-2 flex-shrink-0" />}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
};

export const RulePreviewTopbar: React.FC<RulePreviewTopbarProps> = ({ rulesContent }) => {
const { selectedEnvironment, setSelectedEnvironment, isMultiFileEnvironment, isHydrated } =
useProjectStore();
const { selectedEnvironment, setSelectedEnvironment, isHydrated } = useProjectStore();

// If state hasn't been hydrated from storage yet, don't render the selector
// This prevents the "blinking" effect when loading persisted state
Expand All @@ -47,16 +114,11 @@ export const RulePreviewTopbar: React.FC<RulePreviewTopbarProps> = ({ rulesConte
<div className="p-2 bg-gray-800 rounded-lg">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center space-y-3 sm:space-y-0 opacity-0">
{/* Invisible placeholder content with the same structure to prevent layout shift */}
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="px-3 py-1 text-xs rounded-md bg-gray-700"></div>
<div className="px-3 py-1 text-xs rounded-md bg-gray-700"></div>
<div className="px-3 py-1 text-xs rounded-md bg-gray-700"></div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4 w-full sm:w-auto">
<div className="min-w-[180px] px-3 py-2 text-sm bg-gray-700 rounded-md"></div>
<div className="text-sm text-gray-400 w-32 h-5"></div>
</div>
<div className="flex items-center space-x-2 w-full sm:w-auto">
<div className="text-sm flex-1 sm:flex-none"></div>
<div className="px-3 py-1 rounded-md"></div>
<div className="px-3 py-1 rounded-md"></div>
<div className="px-3 py-1 rounded-md"></div>
</div>
Expand All @@ -68,20 +130,13 @@ export const RulePreviewTopbar: React.FC<RulePreviewTopbarProps> = ({ rulesConte
return (
<div className="p-2 bg-gray-800 rounded-lg">
<div className="flex flex-col sm:flex-row justify-between items-start space-y-3 sm:space-y-0">
{/* Left side: Environment selector buttons and path */}
<div className="flex flex-col space-y-2 w-full sm:w-auto">
{/* Environment selector buttons - make them wrap on small screens */}
<div className="flex flex-wrap gap-1">
{Object.values(AIEnvironmentName).map((environment) => (
<EnvButton
key={'button-' + environment}
environment={environment}
selectedEnvironment={selectedEnvironment}
isMultiFileEnvironment={isMultiFileEnvironment}
onSetSelectedEnvironment={setSelectedEnvironment}
/>
))}
</div>
{/* Left side: Environment selector dropdown and path */}
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4 w-full sm:w-auto">
{/* Environment selector dropdown */}
<EnvironmentDropdown
selectedEnvironment={selectedEnvironment}
onSetSelectedEnvironment={setSelectedEnvironment}
/>

{/* Path display */}
<RulesPath />
Expand Down
5 changes: 4 additions & 1 deletion src/components/rule-preview/RulesPath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ export const RulesPath: React.FC = () => {
const { selectedEnvironment } = useProjectStore();

// Get the appropriate file path based on the selected format
const getFilePath = (): string => aiEnvironmentConfig[selectedEnvironment].filePath;
const getFilePath = (): string => {
const config = aiEnvironmentConfig[selectedEnvironment];
return config?.filePath || 'Unknown path';
};

return (
<div className="text-sm text-gray-400 w-full break-all">
Expand Down
14 changes: 8 additions & 6 deletions src/components/rule-preview/RulesPreviewActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ import Tooltip from '../ui/Tooltip.tsx';
export const RulesPreviewActions: React.FC<unknown> = () => {
const { selectedEnvironment } = useProjectStore();

const config = aiEnvironmentConfig[selectedEnvironment];
if (!config) {
return null;
}

return (
<Tooltip
content={`Open documentation for ${selectedEnvironment.charAt(0).toUpperCase() + selectedEnvironment.slice(1)}`}
position="bottom"
>
<Tooltip content={`Open documentation for ${config.displayName}`} position="bottom">
<a
href={aiEnvironmentConfig[selectedEnvironment].docsUrl}
href={config.docsUrl}
target="_blank"
className="px-3 py-1 bg-purple-700 text-white rounded-md hover:bg-purple-600 flex items-center text-sm opacity-40 hover:opacity-100 cursor-pointer"
aria-label={`Open documentation for ${selectedEnvironment}`}
aria-label={`Open documentation for ${config.displayName}`}
>
<ExternalLink className="h-4 w-4" />
</a>
Expand Down
Loading