initial wcag-ada

This commit is contained in:
Boki 2025-06-28 11:11:34 -04:00
parent 042b8cb83a
commit d52cfe7de2
112 changed files with 9069 additions and 0 deletions

View file

@ -0,0 +1,70 @@
# Dependencies
**/node_modules
**/.pnp
**/.pnp.js
# Testing
**/coverage
**/.nyc_output
# Production builds
**/dist
**/build
**/.next
**/out
# Logs
**/*.log
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/lerna-debug.log*
**/.pnpm-debug.log*
# Environment files
**/.env
**/.env.local
**/.env.development.local
**/.env.test.local
**/.env.production.local
# IDE
**/.idea
**/.vscode
**/*.swp
**/*.swo
**/*~
**/.DS_Store
# Git
**/.git
**/.gitignore
# Docker
**/Dockerfile
**/docker-compose*.yml
**/.dockerignore
# Documentation
**/README.md
**/CHANGELOG.md
**/docs
# Misc
**/.eslintcache
**/.cache
**/tmp
**/.turbo
# Database
**/prisma/migrations/migration_lock.toml
**/*.db
**/*.db-journal
# Only for root level (not in apps/*)
/apps/stock
/apps/db-design
/apps/cline
/lib/!(service)
!/lib/service/core-config
!/lib/service/core-logger

View file

@ -0,0 +1,61 @@
# Application Environment
NODE_ENV=development
APP_NAME=wcag-ada
# API Configuration
API_PORT=3001
API_CORS_ORIGIN=http://localhost:5173
API_JWT_SECRET=your-super-secret-jwt-key-change-in-production
API_JWT_EXPIRES_IN=7d
API_RATE_LIMIT=100
API_RATE_WINDOW=60000
# Worker Configuration
WORKER_PORT=3002
WORKER_CONCURRENCY=5
WORKER_QUEUE_NAME=accessibility-scans
# Database Configuration
DATABASE_URL=postgresql://user:password@localhost:5432/wcag_ada
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Scanner Configuration
SCANNER_HEADLESS=true
SCANNER_TIMEOUT=30000
SCANNER_VIEWPORT_WIDTH=1920
SCANNER_VIEWPORT_HEIGHT=1080
SCANNER_BLOCK_RESOURCES=true
SCANNER_BLOCK_PATTERNS=font,image
# Features
FEATURES_SCREENSHOTS=true
FEATURES_FIX_SUGGESTIONS=true
FEATURES_CUSTOM_RULES=true
FEATURES_SCHEDULED_SCANS=true
FEATURES_BULK_SCANNING=false
FEATURES_API_ACCESS=true
# Third-party Services (optional)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@wcag-ada.com
SLACK_WEBHOOK_URL=
SLACK_CHANNEL=#wcag-alerts
# Logging
LOG_LEVEL=info
LOG_FORMAT=json
# Scheduler
SCHEDULER_TIMEZONE=America/New_York
SCHEDULER_MAX_CONCURRENT_SCANS=3
SCHEDULER_RETRY_ATTEMPTS=3
SCHEDULER_RETRY_DELAY=5000

View file

@ -0,0 +1,181 @@
stages:
- build
- test
- deploy
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
REGISTRY: $CI_REGISTRY
API_IMAGE: $CI_REGISTRY_IMAGE/api
WORKER_IMAGE: $CI_REGISTRY_IMAGE/worker
DASHBOARD_IMAGE: $CI_REGISTRY_IMAGE/dashboard
# Cache node_modules between jobs
.node_cache: &node_cache
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- apps/wcag-ada/*/node_modules/
- .bun/
# Docker login before each job
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
# Build stage
build:api:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- docker build -f apps/wcag-ada/api/Dockerfile -t $API_IMAGE:$CI_COMMIT_SHA .
- docker tag $API_IMAGE:$CI_COMMIT_SHA $API_IMAGE:latest
- docker push $API_IMAGE:$CI_COMMIT_SHA
- docker push $API_IMAGE:latest
only:
- main
- develop
- merge_requests
tags:
- docker
build:worker:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- docker build -f apps/wcag-ada/worker/Dockerfile -t $WORKER_IMAGE:$CI_COMMIT_SHA .
- docker tag $WORKER_IMAGE:$CI_COMMIT_SHA $WORKER_IMAGE:latest
- docker push $WORKER_IMAGE:$CI_COMMIT_SHA
- docker push $WORKER_IMAGE:latest
only:
- main
- develop
- merge_requests
tags:
- docker
build:dashboard:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- docker build -f apps/wcag-ada/dashboard/Dockerfile -t $DASHBOARD_IMAGE:$CI_COMMIT_SHA .
- docker tag $DASHBOARD_IMAGE:$CI_COMMIT_SHA $DASHBOARD_IMAGE:latest
- docker push $DASHBOARD_IMAGE:$CI_COMMIT_SHA
- docker push $DASHBOARD_IMAGE:latest
only:
- main
- develop
- merge_requests
tags:
- docker
# Test stage
test:unit:
stage: test
image: oven/bun:1-alpine
<<: *node_cache
before_script:
- cd apps/wcag-ada
- bun install
script:
- bun test
only:
- merge_requests
tags:
- docker
test:lint:
stage: test
image: oven/bun:1-alpine
<<: *node_cache
before_script:
- cd apps/wcag-ada
- bun install
script:
- bun run lint
only:
- merge_requests
tags:
- docker
test:typecheck:
stage: test
image: oven/bun:1-alpine
<<: *node_cache
before_script:
- cd apps/wcag-ada
- bun install
script:
- bun run typecheck
only:
- merge_requests
tags:
- docker
# Deploy to staging
deploy:staging:
stage: deploy
image: bitnami/kubectl:latest
before_script:
- kubectl config set-cluster k8s --server="$KUBE_URL" --insecure-skip-tls-verify=true
- kubectl config set-credentials gitlab --token="$KUBE_TOKEN"
- kubectl config set-context default --cluster=k8s --user=gitlab
- kubectl config use-context default
script:
- kubectl apply -f apps/wcag-ada/k8s/namespace.yaml
- kubectl apply -f apps/wcag-ada/k8s/configmap.yaml
- kubectl apply -f apps/wcag-ada/k8s/secrets.yaml
- kubectl apply -f apps/wcag-ada/k8s/postgres.yaml
- kubectl apply -f apps/wcag-ada/k8s/redis.yaml
- |
kubectl set image deployment/wcag-ada-api api=$API_IMAGE:$CI_COMMIT_SHA -n wcag-ada
kubectl set image deployment/wcag-ada-worker worker=$WORKER_IMAGE:$CI_COMMIT_SHA -n wcag-ada
kubectl set image deployment/wcag-ada-dashboard dashboard=$DASHBOARD_IMAGE:$CI_COMMIT_SHA -n wcag-ada
- kubectl rollout status deployment/wcag-ada-api -n wcag-ada
- kubectl rollout status deployment/wcag-ada-worker -n wcag-ada
- kubectl rollout status deployment/wcag-ada-dashboard -n wcag-ada
environment:
name: staging
url: https://staging.wcag-ada.example.com
only:
- develop
tags:
- docker
# Deploy to production
deploy:production:
stage: deploy
image: bitnami/kubectl:latest
before_script:
- kubectl config set-cluster k8s --server="$KUBE_URL_PROD" --insecure-skip-tls-verify=true
- kubectl config set-credentials gitlab --token="$KUBE_TOKEN_PROD"
- kubectl config set-context default --cluster=k8s --user=gitlab
- kubectl config use-context default
script:
- kubectl apply -f apps/wcag-ada/k8s/namespace.yaml
- kubectl apply -f apps/wcag-ada/k8s/configmap.yaml
- kubectl apply -f apps/wcag-ada/k8s/secrets.yaml
- kubectl apply -f apps/wcag-ada/k8s/postgres.yaml
- kubectl apply -f apps/wcag-ada/k8s/redis.yaml
- |
kubectl set image deployment/wcag-ada-api api=$API_IMAGE:$CI_COMMIT_SHA -n wcag-ada
kubectl set image deployment/wcag-ada-worker worker=$WORKER_IMAGE:$CI_COMMIT_SHA -n wcag-ada
kubectl set image deployment/wcag-ada-dashboard dashboard=$DASHBOARD_IMAGE:$CI_COMMIT_SHA -n wcag-ada
- kubectl rollout status deployment/wcag-ada-api -n wcag-ada
- kubectl rollout status deployment/wcag-ada-worker -n wcag-ada
- kubectl rollout status deployment/wcag-ada-dashboard -n wcag-ada
environment:
name: production
url: https://wcag-ada.example.com
only:
- main
when: manual
tags:
- docker

328
apps/wcag-ada/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,328 @@
# WCAG-ADA Deployment Guide
## Overview
This guide covers deployment options for the WCAG-ADA Compliance Monitoring Platform.
## Table of Contents
1. [Local Development](#local-development)
2. [Docker Deployment](#docker-deployment)
3. [Kubernetes Deployment](#kubernetes-deployment)
4. [GitLab CI/CD](#gitlab-cicd)
5. [Configuration Management](#configuration-management)
6. [Monitoring & Maintenance](#monitoring--maintenance)
## Local Development
### Quick Start
```bash
# Run the setup script
./scripts/local-dev.sh
# Start all services
bun run dev
```
### Manual Setup
1. **Start development databases:**
```bash
docker-compose -f docker-compose.dev.yml up -d
```
2. **Configure environment:**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Install dependencies:**
```bash
bun install
```
4. **Run migrations:**
```bash
cd api && bunx prisma migrate dev
```
5. **Start services:**
```bash
# Terminal 1 - API
cd api && bun run dev
# Terminal 2 - Worker
cd worker && bun run dev
# Terminal 3 - Dashboard
cd dashboard && bun run dev
```
## Docker Deployment
### Build Images
```bash
# Using the build script
./scripts/build-images.sh
# Or manually with docker-compose
docker-compose build
```
### Run with Docker Compose
```bash
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
### Production Docker Deployment
1. **Set environment variables:**
```bash
export API_JWT_SECRET=your-secret-key
export DATABASE_URL=postgresql://user:pass@host:5432/db
# ... other required variables
```
2. **Run with production config:**
```bash
docker-compose -f docker-compose.yml up -d
```
## Kubernetes Deployment
### Prerequisites
- Kubernetes cluster (1.24+)
- kubectl configured
- Ingress controller installed
- cert-manager (for TLS)
### Deploy to Kubernetes
```bash
# Using the deploy script
./scripts/deploy-k8s.sh
# Or manually
kubectl apply -f k8s/
```
### Configuration
1. **Update secrets:**
```bash
# Edit k8s/secrets.yaml with your values
# Then apply
kubectl apply -f k8s/secrets.yaml -n wcag-ada
```
2. **Update ingress hosts:**
```bash
# Edit k8s/ingress.yaml
# Replace example.com with your domain
kubectl apply -f k8s/ingress.yaml -n wcag-ada
```
### Scaling
```bash
# Scale API replicas
kubectl scale deployment wcag-ada-api --replicas=5 -n wcag-ada
# Scale Worker replicas
kubectl scale deployment wcag-ada-worker --replicas=3 -n wcag-ada
```
## GitLab CI/CD
### Setup
1. **Configure GitLab Container Registry:**
- Enable container registry in project settings
- Note your registry URL
2. **Add CI/CD Variables:**
```
CI_REGISTRY_USER - GitLab username
CI_REGISTRY_PASSWORD - GitLab access token
KUBE_URL - Kubernetes API URL (staging)
KUBE_TOKEN - Kubernetes service account token (staging)
KUBE_URL_PROD - Kubernetes API URL (production)
KUBE_TOKEN_PROD - Kubernetes service account token (production)
```
3. **Configure environments:**
- Create `staging` and `production` environments in GitLab
- Set appropriate URLs
### Deployment Flow
1. **Development:**
- Push to `develop` branch
- Automatically builds and deploys to staging
2. **Production:**
- Merge to `main` branch
- Builds images automatically
- Manual approval required for production deployment
## Configuration Management
### Environment Variables
Key configuration variables:
```bash
# Application
NODE_ENV=production
APP_NAME=wcag-ada
# API
API_PORT=3001
API_JWT_SECRET=<strong-secret>
API_CORS_ORIGIN=https://your-domain.com
# Database
DATABASE_URL=postgresql://user:password@host:5432/wcag_ada
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=<redis-password>
# Worker
WORKER_CONCURRENCY=5
WORKER_QUEUE_NAME=accessibility-scans
# Scanner
SCANNER_HEADLESS=true
SCANNER_TIMEOUT=30000
```
### Configuration Priority
1. Command-line arguments (highest)
2. Environment variables
3. Configuration files
4. Default values (lowest)
### Secrets Management
- **Development:** Use `.env` files
- **Docker:** Use environment variables or Docker secrets
- **Kubernetes:** Use Kubernetes secrets
- **Production:** Consider using:
- HashiCorp Vault
- AWS Secrets Manager
- Azure Key Vault
- GitLab CI/CD variables
## Monitoring & Maintenance
### Health Checks
All services expose health endpoints:
- API: `http://api:3001/health`
- Worker: `http://worker:3002/health`
- Dashboard: `http://dashboard:8080/health`
### Monitoring Stack
Recommended monitoring setup:
1. **Metrics:** Prometheus + Grafana
2. **Logs:** ELK Stack or Loki
3. **Traces:** Jaeger or Zipkin
4. **Uptime:** Uptime Kuma or Pingdom
### Database Maintenance
```bash
# Backup PostgreSQL
kubectl exec -it postgres-pod -n wcag-ada -- pg_dump -U wcag_user wcag_ada > backup.sql
# Backup Redis
kubectl exec -it redis-pod -n wcag-ada -- redis-cli BGSAVE
```
### Updates and Migrations
1. **Database migrations:**
```bash
# Run migrations before deployment
kubectl run migration --rm -it --image=wcag-ada/api:latest -- bunx prisma migrate deploy
```
2. **Rolling updates:**
```bash
# Update image
kubectl set image deployment/wcag-ada-api api=wcag-ada/api:new-version -n wcag-ada
# Monitor rollout
kubectl rollout status deployment/wcag-ada-api -n wcag-ada
```
### Troubleshooting
1. **View logs:**
```bash
# Kubernetes
kubectl logs -f deployment/wcag-ada-api -n wcag-ada
# Docker
docker-compose logs -f api
```
2. **Debug pods:**
```bash
# Get pod details
kubectl describe pod <pod-name> -n wcag-ada
# Execute commands in pod
kubectl exec -it <pod-name> -n wcag-ada -- /bin/sh
```
3. **Common issues:**
- Database connection: Check DATABASE_URL and network connectivity
- Redis connection: Verify Redis is running and accessible
- Browser issues: Ensure Chromium dependencies are installed
- Memory issues: Increase resource limits in Kubernetes
## Security Considerations
1. **Network Security:**
- Use NetworkPolicies in Kubernetes
- Implement proper firewall rules
- Use TLS for all external communications
2. **Application Security:**
- Rotate JWT secrets regularly
- Use strong passwords for databases
- Implement rate limiting
- Enable CORS appropriately
3. **Container Security:**
- Run containers as non-root user
- Use minimal base images
- Scan images for vulnerabilities
- Keep dependencies updated
## Support
For issues or questions:
1. Check application logs
2. Review health check endpoints
3. Consult error messages in dashboard
4. Check GitLab CI/CD pipeline status

85
apps/wcag-ada/Dockerfile Normal file
View file

@ -0,0 +1,85 @@
# This is a multi-service Dockerfile that builds all services
# Use with --target flag to build specific services
# Base stage for all services
FROM oven/bun:1-alpine as base
RUN apk add --no-cache openssl
WORKDIR /app
# Dependencies stage
FROM base as deps
COPY package.json bun.lockb ./
COPY apps/wcag-ada/*/package.json ./apps/wcag-ada/
COPY lib/service/core-config/package.json ./lib/service/core-config/
COPY lib/service/core-logger/package.json ./lib/service/core-logger/
RUN bun install --frozen-lockfile
# Builder stage
FROM base as builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package.json ./package.json
COPY . .
WORKDIR /app/apps/wcag-ada
# Generate Prisma clients
RUN cd api && bunx prisma generate
RUN cd worker && bunx prisma generate
# Build all services
RUN bun run build
# API Runtime
FROM base as api
COPY --from=builder /app/apps/wcag-ada/api/dist ./dist
COPY --from=builder /app/apps/wcag-ada/api/node_modules ./node_modules
COPY --from=builder /app/apps/wcag-ada/api/prisma ./prisma
COPY --from=builder /app/apps/wcag-ada/api/package.json ./
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
CMD ["bun", "run", "start"]
# Worker Runtime
FROM base as worker
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
COPY --from=builder /app/apps/wcag-ada/worker/dist ./dist
COPY --from=builder /app/apps/wcag-ada/worker/node_modules ./node_modules
COPY --from=builder /app/apps/wcag-ada/worker/prisma ./prisma
COPY --from=builder /app/apps/wcag-ada/worker/package.json ./
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3002
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
CMD ["bun", "run", "start"]
# Dashboard Runtime
FROM nginx:alpine as dashboard
RUN apk add --no-cache curl
COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
RUN chown -R nodejs:nodejs /usr/share/nginx/html /var/cache/nginx /var/log/nginx /etc/nginx/conf.d
RUN touch /var/run/nginx.pid && chown -R nodejs:nodejs /var/run/nginx.pid
USER nodejs
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

85
apps/wcag-ada/Makefile Normal file
View file

@ -0,0 +1,85 @@
.PHONY: help
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: dev-up
dev-up: ## Start development databases (PostgreSQL and Redis)
docker-compose -f docker-compose.dev.yml up -d
.PHONY: dev-down
dev-down: ## Stop development databases
docker-compose -f docker-compose.dev.yml down
.PHONY: dev-logs
dev-logs: ## Show development database logs
docker-compose -f docker-compose.dev.yml logs -f
.PHONY: build
build: ## Build all Docker images
docker-compose build
.PHONY: up
up: ## Start all services
docker-compose up -d
.PHONY: down
down: ## Stop all services
docker-compose down
.PHONY: logs
logs: ## Show logs for all services
docker-compose logs -f
.PHONY: logs-api
logs-api: ## Show API service logs
docker-compose logs -f api
.PHONY: logs-worker
logs-worker: ## Show worker service logs
docker-compose logs -f worker
.PHONY: restart
restart: ## Restart all services
docker-compose restart
.PHONY: restart-api
restart-api: ## Restart API service
docker-compose restart api
.PHONY: restart-worker
restart-worker: ## Restart worker service
docker-compose restart worker
.PHONY: ps
ps: ## Show running services
docker-compose ps
.PHONY: exec-api
exec-api: ## Execute bash in API container
docker-compose exec api /bin/sh
.PHONY: exec-worker
exec-worker: ## Execute bash in worker container
docker-compose exec worker /bin/sh
.PHONY: migrate
migrate: ## Run database migrations
docker-compose run --rm migrate
.PHONY: clean
clean: ## Remove all containers and volumes
docker-compose down -v
.PHONY: rebuild
rebuild: clean build up ## Clean, rebuild, and start all services
.PHONY: test-scan
test-scan: ## Run a test accessibility scan
@echo "Running test scan on example.com..."
@curl -X POST http://localhost:3001/api/scans \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{"websiteId": "test-website-id", "url": "https://example.com"}'

175
apps/wcag-ada/README.md Normal file
View file

@ -0,0 +1,175 @@
# WCAG-ADA Compliance Monitoring Platform
An automated web accessibility compliance monitoring system that continuously scans websites for WCAG and ADA compliance issues, provides detailed reports, and offers remediation suggestions.
## Architecture
The platform consists of four main services:
### 1. **Scanner Service** (`/scanner`)
- Performs accessibility scans using axe-core and Playwright
- Supports WCAG 2.0, 2.1, and 2.2 compliance levels (A, AA, AAA)
- Generates detailed violation reports with fix suggestions
- Captures screenshots of accessibility issues
### 2. **API Service** (`/api`)
- RESTful API built with Hono framework
- JWT-based authentication
- Redis-backed rate limiting
- PostgreSQL database with Prisma ORM
- BullMQ integration for job queuing
### 3. **Worker Service** (`/worker`)
- Processes scan jobs from the queue
- Runs scheduled scans based on website configurations
- Handles scan result processing and storage
- Provides health monitoring endpoints
### 4. **Dashboard** (`/dashboard`)
- React 18 SPA with Vite
- Real-time scan monitoring
- Compliance reporting and analytics
- Website and scan management
- Built with TailwindCSS and Radix UI
## Configuration
The application uses a sophisticated configuration system that loads settings from multiple sources:
1. Default values (defined in schemas)
2. Configuration files (`.env`, `config.json`)
3. Environment variables
4. Command-line arguments
### Key Configuration Files
- `/config/src/schemas/` - Zod schemas defining all configuration options
- `/config/src/config-instance.ts` - Main configuration loader
- `.env` - Environment-specific settings
## Getting Started
### Prerequisites
- Node.js 18+
- PostgreSQL
- Redis
- Bun (recommended) or npm/yarn
### Installation
1. Install dependencies:
```bash
bun install
```
2. Set up the database:
```bash
cd apps/wcag-ada/api
bunx prisma migrate dev
```
3. Configure environment variables:
```bash
cp .env.example .env
# Edit .env with your settings
```
### Running Services
Start all services concurrently:
```bash
bun run dev
```
Or run individual services:
```bash
# API
cd apps/wcag-ada/api
bun run dev
# Worker
cd apps/wcag-ada/worker
bun run dev
# Dashboard
cd apps/wcag-ada/dashboard
bun run dev
# Scanner (for testing)
cd apps/wcag-ada/scanner
bun run example
```
## API Endpoints
### Authentication
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - User login
- `GET /api/auth/me` - Get current user
### Websites
- `GET /api/websites` - List user's websites
- `POST /api/websites` - Add new website
- `PUT /api/websites/:id` - Update website
- `DELETE /api/websites/:id` - Delete website
### Scans
- `POST /api/scans` - Start new scan
- `GET /api/scans/:id` - Get scan status
- `GET /api/scans/:id/result` - Get scan results
- `GET /api/scans/:id/violations` - Get violations
- `DELETE /api/scans/:id` - Cancel scan
### Reports
- `GET /api/reports/compliance/:websiteId` - Get compliance report
- `GET /api/reports/trends/:websiteId` - Get trend analysis
### Health
- `GET /health` - Service health check
- `GET /health/stats` - Detailed statistics
## Development
### Project Structure
```
apps/wcag-ada/
├── api/ # REST API service
├── config/ # Configuration management
├── dashboard/ # React web interface
├── scanner/ # Accessibility scanning engine
├── shared/ # Shared types and utilities
└── worker/ # Background job processor
```
### Testing
Run tests for all services:
```bash
bun test
```
### Building for Production
```bash
bun run build
```
## Deployment
The application is designed to be deployed as separate microservices. Each service can be containerized and deployed independently.
### Docker Support
Build Docker images:
```bash
docker build -t wcag-ada-api -f apps/wcag-ada/api/Dockerfile .
docker build -t wcag-ada-worker -f apps/wcag-ada/worker/Dockerfile .
docker build -t wcag-ada-dashboard -f apps/wcag-ada/dashboard/Dockerfile .
```
## License
Proprietary - All rights reserved

View file

@ -0,0 +1,40 @@
# Database URL (required for Prisma)
DATABASE_URL=postgresql://user:password@localhost:5432/wcag_ada
# Environment variables for configuration overrides
# These follow the WCAG_ prefix convention
# Override service ports
# WCAG_SERVICES_API_PORT=3001
# WCAG_SERVICES_DASHBOARD_PORT=3000
# WCAG_SERVICES_WORKER_PORT=3002
# Override database settings
# WCAG_DATABASE_POSTGRES_HOST=localhost
# WCAG_DATABASE_POSTGRES_PORT=5432
# WCAG_DATABASE_POSTGRES_DATABASE=wcag_ada
# Override Redis settings
# WCAG_WORKER_REDIS_HOST=localhost
# WCAG_WORKER_REDIS_PORT=6379
# WCAG_WORKER_REDIS_PASSWORD=
# WCAG_WORKER_REDIS_DB=2
# Override JWT settings
# WCAG_PROVIDERS_AUTH_JWT_SECRET=your-super-secret-key
# WCAG_PROVIDERS_AUTH_JWT_EXPIRES_IN=7d
# Override scanner settings
# WCAG_SCANNER_CONCURRENCY=2
# WCAG_SCANNER_TIMEOUT=120000
# WCAG_SCANNER_HEADLESS=true
# Override storage settings
# WCAG_PROVIDERS_STORAGE_TYPE=local
# WCAG_PROVIDERS_STORAGE_LOCAL_BASE_PATH=/tmp/wcag-ada
# Override email settings
# WCAG_PROVIDERS_EMAIL_SMTP_HOST=localhost
# WCAG_PROVIDERS_EMAIL_SMTP_PORT=587
# WCAG_PROVIDERS_EMAIL_SMTP_AUTH_USER=
# WCAG_PROVIDERS_EMAIL_SMTP_AUTH_PASS=

View file

@ -0,0 +1,66 @@
# Build stage
FROM oven/bun:1-alpine as builder
# Install dependencies for Prisma
RUN apk add --no-cache openssl
WORKDIR /app
# Copy workspace files
COPY package.json bun.lockb ./
COPY apps/wcag-ada/api/package.json ./apps/wcag-ada/api/
COPY apps/wcag-ada/config/package.json ./apps/wcag-ada/config/
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
COPY lib/service/core-config/package.json ./lib/service/core-config/
COPY lib/service/core-logger/package.json ./lib/service/core-logger/
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY apps/wcag-ada/api ./apps/wcag-ada/api
COPY apps/wcag-ada/config ./apps/wcag-ada/config
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
COPY lib/service/core-config ./lib/service/core-config
COPY lib/service/core-logger ./lib/service/core-logger
COPY tsconfig.json ./
# Generate Prisma client
WORKDIR /app/apps/wcag-ada/api
RUN bunx prisma generate
# Build the application
RUN bun run build
# Production stage
FROM oven/bun:1-alpine
# Install runtime dependencies
RUN apk add --no-cache openssl
WORKDIR /app
# Copy built application
COPY --from=builder /app/apps/wcag-ada/api/dist ./dist
COPY --from=builder /app/apps/wcag-ada/api/node_modules ./node_modules
COPY --from=builder /app/apps/wcag-ada/api/prisma ./prisma
COPY --from=builder /app/apps/wcag-ada/api/package.json ./
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set ownership
RUN chown -R nodejs:nodejs /app
USER nodejs
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
# Start the application
CMD ["bun", "run", "start"]

View file

@ -0,0 +1,34 @@
{
"name": "@wcag-ada/api",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "bun run --hot src/index.ts",
"build": "tsc -b",
"start": "bun run dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"hono": "^3.11.7",
"@hono/node-server": "^1.4.0",
"@hono/zod-validator": "^0.5.0",
"zod": "^3.22.4",
"@wcag-ada/shared": "workspace:*",
"@wcag-ada/scanner": "workspace:*",
"@wcag-ada/config": "workspace:*",
"jsonwebtoken": "^9.0.2",
"bcryptjs": "^2.4.3",
"nanoid": "^5.0.4",
"@prisma/client": "^5.8.0",
"bullmq": "^5.1.1",
"ioredis": "^5.3.2"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/jsonwebtoken": "^9.0.5",
"@types/bcryptjs": "^2.4.6",
"prisma": "^5.8.0",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,153 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
company String?
role UserRole @default(USER)
apiKey String? @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
websites Website[]
reports Report[]
scanJobs ScanJob[]
}
model Website {
id String @id @default(cuid())
name String
url String
userId String
user User @relation(fields: [userId], references: [id])
scanSchedule Json? // ScanSchedule type
lastScanAt DateTime?
complianceScore Float?
tags String[]
active Boolean @default(true)
authConfig Json? // AuthenticationConfig type
scanOptions Json? // Default scan options
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scanResults ScanResult[]
scanJobs ScanJob[]
reports Report[]
@@index([userId])
@@index([url])
}
model ScanResult {
id String @id @default(cuid())
websiteId String
website Website @relation(fields: [websiteId], references: [id])
jobId String? @unique
job ScanJob? @relation(fields: [jobId], references: [id])
url String
scanDuration Int
summary Json // ScanSummary type
violations Json // AccessibilityViolation[] type
passes Json // AxeResult[] type
incomplete Json // AxeResult[] type
inapplicable Json // AxeResult[] type
pageMetadata Json // PageMetadata type
wcagCompliance Json // WCAGCompliance type
createdAt DateTime @default(now())
@@index([websiteId])
@@index([createdAt])
}
model ScanJob {
id String @id @default(cuid())
websiteId String
website Website @relation(fields: [websiteId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
url String
status JobStatus @default(PENDING)
options Json // AccessibilityScanOptions type
scheduledAt DateTime @default(now())
startedAt DateTime?
completedAt DateTime?
error String?
retryCount Int @default(0)
maxRetries Int @default(3)
result ScanResult?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([websiteId])
@@index([status])
@@index([scheduledAt])
}
model Report {
id String @id @default(cuid())
websiteId String
website Website @relation(fields: [websiteId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
type ReportType
format ReportFormat
period Json // ReportPeriod type
summary Json // ReportSummary type
data Json // Report data
fileUrl String?
generatedAt DateTime @default(now())
@@index([websiteId])
@@index([userId])
@@index([generatedAt])
}
enum UserRole {
USER
ADMIN
}
enum JobStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
enum ReportType {
COMPLIANCE
EXECUTIVE
TECHNICAL
TREND
}
enum ReportFormat {
PDF
HTML
JSON
CSV
}

View file

@ -0,0 +1,39 @@
import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config';
// Initialize configuration for API service
const appConfig = initializeWcagConfig('api');
// Export a config object that matches the expected interface
export const config = {
NODE_ENV: appConfig.environment as 'development' | 'production' | 'test',
PORT: appConfig.services.api.port,
// Database
DATABASE_URL: process.env.DATABASE_URL ||
`postgresql://${appConfig.database.postgres.user || 'postgres'}:${appConfig.database.postgres.password || 'postgres'}@${appConfig.database.postgres.host}:${appConfig.database.postgres.port}/${appConfig.database.postgres.database}`,
REDIS_URL: `redis://${appConfig.worker.redis.host}:${appConfig.worker.redis.port}/${appConfig.worker.redis.db}`,
// Auth
JWT_SECRET: appConfig.providers.auth.jwt.secret,
JWT_EXPIRES_IN: appConfig.providers.auth.jwt.expiresIn,
// Scanner
SCANNER_CONCURRENCY: appConfig.scanner.concurrency,
SCANNER_TIMEOUT: appConfig.scanner.timeout,
// API Rate Limiting
API_RATE_LIMIT: appConfig.services.api.rateLimit.max,
API_RATE_WINDOW: appConfig.services.api.rateLimit.windowMs,
// Storage
REPORT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.reports}`,
SCREENSHOT_STORAGE_PATH: `${appConfig.providers.storage.local.basePath}/${appConfig.providers.storage.local.screenshots}`,
// CORS
CORS_ORIGIN: appConfig.services.api.cors.origin,
};
export type Config = typeof config;
// Export the full app config for advanced usage
export { appConfig };

View file

@ -0,0 +1,64 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger as honoLogger } from 'hono/logger';
import { compress } from 'hono/compress';
import { secureHeaders } from 'hono/secure-headers';
import { config, appConfig } from './config';
import { logger } from './utils/logger';
// Import routes
import { authRoutes } from './routes/auth';
import { websiteRoutes } from './routes/websites';
import { scanRoutes } from './routes/scans';
import { reportRoutes } from './routes/reports';
import { healthRoutes } from './routes/health';
// Import middleware
import { errorHandler } from './middleware/error-handler';
import { rateLimiter } from './middleware/rate-limiter';
const app = new Hono();
// Global middleware
app.use('*', honoLogger());
app.use('*', cors({
origin: config.CORS_ORIGIN || '*',
credentials: true,
}));
app.use('*', compress());
app.use('*', secureHeaders());
// Rate limiting
app.use('/api/*', rateLimiter());
// Health check (no auth required)
app.route('/health', healthRoutes);
// API routes
app.route('/api/auth', authRoutes);
app.route('/api/websites', websiteRoutes);
app.route('/api/scans', scanRoutes);
app.route('/api/reports', reportRoutes);
// 404 handler
app.notFound((c) => {
return c.json({ error: 'Not Found' }, 404);
});
// Global error handler
app.onError(errorHandler);
// Start server
const port = config.PORT || 3001;
logger.info(`🚀 WCAG-ADA API Server starting on port ${port}...`);
serve({
fetch: app.fetch,
port,
}, (info) => {
logger.info(`✅ Server is running on http://localhost:${info.port}`);
});
export default app;

View file

@ -0,0 +1,69 @@
import { Context, Next } from 'hono';
import { verify } from 'hono/jwt';
import { prisma } from '../utils/prisma';
import { config } from '../config';
export const authenticate = async (c: Context, next: Next) => {
try {
// Check for Bearer token
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const payload = await verify(token, config.JWT_SECRET, 'HS256') as {
sub: string;
email: string;
role: string;
};
c.set('userId', payload.sub);
c.set('userRole', payload.role);
c.set('authType', 'jwt');
return next();
} catch (error) {
// Invalid JWT, continue to check API key
}
}
// Check for API key
const apiKey = c.req.header('X-API-Key');
if (apiKey) {
const user = await prisma.user.findUnique({
where: {
apiKey,
isActive: true,
},
select: {
id: true,
role: true,
},
});
if (user) {
c.set('userId', user.id);
c.set('userRole', user.role);
c.set('authType', 'apiKey');
return next();
}
}
return c.json({ error: 'Unauthorized' }, 401);
} catch (error) {
return c.json({ error: 'Authentication failed' }, 401);
}
};
export const requireRole = (role: string) => {
return async (c: Context, next: Next) => {
const userRole = c.get('userRole');
if (!userRole || userRole !== role) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
return next();
};
};

View file

@ -0,0 +1,59 @@
import { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { ZodError } from 'zod';
import { appConfig } from '../config';
import { logger } from '../utils/logger';
export const errorHandler = (err: Error, c: Context) => {
logger.error('Error:', err);
// Handle Hono HTTP exceptions
if (err instanceof HTTPException) {
return c.json(
{ error: err.message },
err.status
);
}
// Handle Zod validation errors
if (err instanceof ZodError) {
return c.json(
{
error: 'Validation error',
details: err.errors.map(e => ({
path: e.path.join('.'),
message: e.message,
})),
},
400
);
}
// Handle Prisma errors
if (err.constructor.name === 'PrismaClientKnownRequestError') {
const prismaError = err as any;
if (prismaError.code === 'P2002') {
return c.json(
{ error: 'Duplicate entry' },
400
);
}
if (prismaError.code === 'P2025') {
return c.json(
{ error: 'Record not found' },
404
);
}
}
// Default error response
return c.json(
{
error: 'Internal server error',
message: appConfig.env === 'development' ? err.message : undefined,
},
500
);
};

View file

@ -0,0 +1,75 @@
import { Context, Next } from 'hono';
import Redis from 'ioredis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { getWorkerConfig } from '@wcag-ada/config';
const workerConfig = getWorkerConfig();
const redis = new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
});
interface RateLimitOptions {
limit?: number;
window?: number; // in milliseconds
keyGenerator?: (c: Context) => string;
}
export const rateLimiter = (options: RateLimitOptions = {}) => {
const {
limit = config.API_RATE_LIMIT,
window = config.API_RATE_WINDOW,
keyGenerator = (c) => {
const userId = c.get('userId');
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
return userId ? `rate:user:${userId}` : `rate:ip:${ip}`;
},
} = options;
return async (c: Context, next: Next) => {
const key = keyGenerator(c);
try {
// Get current count
const current = await redis.get(key);
const count = current ? parseInt(current) : 0;
// Check if limit exceeded
if (count >= limit) {
const ttl = await redis.pttl(key);
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', '0');
c.header('X-RateLimit-Reset', new Date(Date.now() + ttl).toISOString());
return c.json(
{
error: 'Rate limit exceeded',
retryAfter: Math.ceil(ttl / 1000),
},
429
);
}
// Increment counter
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.pexpire(key, window);
await pipeline.exec();
// Set headers
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', (limit - count - 1).toString());
c.header('X-RateLimit-Reset', new Date(Date.now() + window).toISOString());
return next();
} catch (error) {
logger.error('Rate limiter error:', error);
// Continue on error - don't block requests due to rate limiter failure
return next();
}
};
};

View file

@ -0,0 +1,177 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { sign } from 'hono/jwt';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { prisma } from '../utils/prisma';
import { config } from '../config';
const authRoutes = new Hono();
// Validation schemas
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional(),
company: z.string().optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
// Register
authRoutes.post('/register', zValidator('json', registerSchema), async (c) => {
const { email, password, name, company } = c.req.valid('json');
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return c.json({ error: 'User already exists' }, 400);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user with API key
const user = await prisma.user.create({
data: {
email,
passwordHash,
name,
company,
apiKey: `wcag_${nanoid(32)}`,
},
select: {
id: true,
email: true,
name: true,
company: true,
apiKey: true,
role: true,
},
});
// Generate JWT
const token = await sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
config.JWT_SECRET,
'HS256'
);
return c.json({
user,
token,
});
});
// Login
authRoutes.post('/login', zValidator('json', loginSchema), async (c) => {
const { email, password } = c.req.valid('json');
// Find user
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
passwordHash: true,
name: true,
company: true,
apiKey: true,
role: true,
isActive: true,
},
});
if (!user || !user.isActive) {
return c.json({ error: 'Invalid credentials' }, 401);
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return c.json({ error: 'Invalid credentials' }, 401);
}
// Generate JWT
const token = await sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
config.JWT_SECRET,
'HS256'
);
// Remove passwordHash from response
const { passwordHash, ...userWithoutPassword } = user;
return c.json({
user: userWithoutPassword,
token,
});
});
// Refresh API key
authRoutes.post('/refresh-api-key', async (c) => {
const userId = c.get('userId');
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const newApiKey = `wcag_${nanoid(32)}`;
const user = await prisma.user.update({
where: { id: userId },
data: { apiKey: newApiKey },
select: {
id: true,
email: true,
apiKey: true,
},
});
return c.json({ apiKey: user.apiKey });
});
// Get current user
authRoutes.get('/me', async (c) => {
const userId = c.get('userId');
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
company: true,
role: true,
apiKey: true,
createdAt: true,
},
});
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json(user);
});
export { authRoutes };

View file

@ -0,0 +1,118 @@
import { Hono } from 'hono';
import { Queue } from 'bullmq';
import { prisma } from '../utils/prisma';
import Redis from 'ioredis';
import { appConfig } from '../config';
import { getWorkerConfig } from '@wcag-ada/config';
const healthRoutes = new Hono();
healthRoutes.get('/', async (c) => {
const checks = {
api: 'ok',
database: 'unknown',
redis: 'unknown',
queue: 'unknown',
};
// Check database
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
} catch (error) {
checks.database = 'error';
}
// Check Redis
try {
const workerConfig = getWorkerConfig();
const redis = new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
});
await redis.ping();
await redis.quit();
checks.redis = 'ok';
} catch (error) {
checks.redis = 'error';
}
// Check queue
try {
const workerConfig = getWorkerConfig();
const queue = new Queue(workerConfig.queueName, {
connection: new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
}),
});
await queue.getJobCounts();
await queue.close();
checks.queue = 'ok';
} catch (error) {
checks.queue = 'error';
}
const allHealthy = Object.values(checks).every(status => status === 'ok');
return c.json(
{
status: allHealthy ? 'healthy' : 'unhealthy',
checks,
timestamp: new Date().toISOString(),
},
allHealthy ? 200 : 503
);
});
healthRoutes.get('/stats', async (c) => {
try {
// Get queue stats
const workerConfig = getWorkerConfig();
const queue = new Queue(workerConfig.queueName, {
connection: new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
}),
});
const [waiting, active, completed, failed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
]);
const queueStats = { waiting, active, completed, failed };
const dbStats = await Promise.all([
prisma.user.count(),
prisma.website.count({ where: { active: true } }),
prisma.scanJob.count(),
prisma.scanResult.count(),
]);
await queue.close();
return c.json({
queue: queueStats,
database: {
users: dbStats[0],
websites: dbStats[1],
scanJobs: dbStats[2],
scanResults: dbStats[3],
},
timestamp: new Date().toISOString(),
});
} catch (error) {
return c.json({ error: 'Failed to get stats' }, 500);
}
});
export { healthRoutes };

View file

@ -0,0 +1,244 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { prisma } from '../utils/prisma';
import { authenticate } from '../middleware/auth';
import { generateReport } from '../services/report-generator';
import type { ReportPeriod } from '@wcag-ada/shared';
const reportRoutes = new Hono();
// Apply authentication to all routes
reportRoutes.use('*', authenticate);
// Validation schemas
const generateReportSchema = z.object({
websiteId: z.string(),
type: z.enum(['COMPLIANCE', 'EXECUTIVE', 'TECHNICAL', 'TREND']),
format: z.enum(['PDF', 'HTML', 'JSON', 'CSV']),
period: z.object({
start: z.string().transform(s => new Date(s)),
end: z.string().transform(s => new Date(s)),
}),
});
// Generate a new report
reportRoutes.post('/generate', zValidator('json', generateReportSchema), async (c) => {
const userId = c.get('userId');
const { websiteId, type, format, period } = c.req.valid('json');
// Check website ownership
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
active: true,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
// Get scan results for the period
const scanResults = await prisma.scanResult.findMany({
where: {
websiteId,
createdAt: {
gte: period.start,
lte: period.end,
},
},
orderBy: { createdAt: 'asc' },
});
if (scanResults.length === 0) {
return c.json({ error: 'No scan data available for the specified period' }, 400);
}
// Generate report data
const reportData = await generateReport({
website,
scanResults,
type,
format,
period,
});
// Save report
const report = await prisma.report.create({
data: {
websiteId,
userId,
type,
format,
period: period as any,
summary: reportData.summary as any,
data: reportData.data as any,
fileUrl: reportData.fileUrl,
},
});
return c.json({
report: {
id: report.id,
type: report.type,
format: report.format,
period: report.period,
fileUrl: report.fileUrl,
generatedAt: report.generatedAt,
},
}, 201);
});
// Get user's reports
reportRoutes.get('/', async (c) => {
const userId = c.get('userId');
const { page = '1', limit = '20', websiteId, type } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
const where = {
userId,
...(websiteId && { websiteId }),
...(type && { type }),
};
const [reports, total] = await Promise.all([
prisma.report.findMany({
where,
skip,
take: limitNum,
orderBy: { generatedAt: 'desc' },
include: {
website: {
select: {
id: true,
name: true,
url: true,
},
},
},
}),
prisma.report.count({ where }),
]);
return c.json({
reports,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
// Get report by ID
reportRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const reportId = c.req.param('id');
const report = await prisma.report.findFirst({
where: {
id: reportId,
userId,
},
include: {
website: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
if (!report) {
return c.json({ error: 'Report not found' }, 404);
}
return c.json(report);
});
// Download report file
reportRoutes.get('/:id/download', async (c) => {
const userId = c.get('userId');
const reportId = c.req.param('id');
const report = await prisma.report.findFirst({
where: {
id: reportId,
userId,
},
});
if (!report || !report.fileUrl) {
return c.json({ error: 'Report file not found' }, 404);
}
// In production, this would redirect to a signed URL or serve from storage
// For now, we'll just return the file URL
return c.json({ downloadUrl: report.fileUrl });
});
// Get compliance trends
reportRoutes.get('/trends/:websiteId', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('websiteId');
const { days = '30' } = c.req.query();
// Check website ownership
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
const daysNum = parseInt(days);
const startDate = new Date();
startDate.setDate(startDate.getDate() - daysNum);
const scanResults = await prisma.scanResult.findMany({
where: {
websiteId,
createdAt: {
gte: startDate,
},
},
orderBy: { createdAt: 'asc' },
select: {
createdAt: true,
summary: true,
wcagCompliance: true,
},
});
const trends = scanResults.map(result => ({
date: result.createdAt,
score: (result.summary as any).score,
violationCount: (result.summary as any).violationCount,
passCount: (result.summary as any).passCount,
criticalIssues: (result.summary as any).criticalIssues,
seriousIssues: (result.summary as any).seriousIssues,
}));
return c.json({
websiteId,
period: {
start: startDate,
end: new Date(),
},
trends,
});
});
export { reportRoutes };

View file

@ -0,0 +1,283 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { Queue } from 'bullmq';
import Redis from 'ioredis';
import { prisma } from '../utils/prisma';
import { authenticate } from '../middleware/auth';
import { getWorkerConfig } from '@wcag-ada/config';
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
const scanRoutes = new Hono();
// Get worker config for queue connection
const workerConfig = getWorkerConfig();
// Create queue connection
const scanQueue = new Queue('accessibility-scans', {
connection: new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
}),
});
// Apply authentication to all routes
scanRoutes.use('*', authenticate);
// Validation schemas
const createScanSchema = z.object({
websiteId: z.string(),
url: z.string().url().optional(),
options: z.object({
wcagLevel: z.object({
level: z.enum(['A', 'AA', 'AAA']),
version: z.enum(['2.0', '2.1', '2.2']),
}).optional(),
viewport: z.object({
width: z.number(),
height: z.number(),
deviceScaleFactor: z.number().optional(),
isMobile: z.boolean().optional(),
}).optional(),
includeScreenshots: z.boolean().optional(),
excludeSelectors: z.array(z.string()).optional(),
waitForSelector: z.string().optional(),
timeout: z.number().optional(),
}).optional(),
});
// Start a new scan
scanRoutes.post('/', zValidator('json', createScanSchema), async (c) => {
const userId = c.get('userId');
const { websiteId, url, options } = c.req.valid('json');
// Get website
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
active: true,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
// Merge options with website defaults
const scanOptions: AccessibilityScanOptions = {
url: url || website.url,
...website.scanOptions as any,
...options,
authenticate: website.authConfig as any,
};
// Create scan job
const job = await prisma.scanJob.create({
data: {
websiteId,
userId,
url: scanOptions.url,
options: scanOptions as any,
status: 'PENDING',
},
});
// Queue the scan
await scanQueue.add('scan', {
jobId: job.id,
websiteId,
userId,
options: scanOptions,
}, {
jobId: job.id,
});
return c.json({
job: {
id: job.id,
status: job.status,
url: job.url,
scheduledAt: job.scheduledAt,
},
message: 'Scan queued successfully',
}, 202);
});
// Get scan status
scanRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const scanId = c.req.param('id');
const job = await prisma.scanJob.findFirst({
where: {
id: scanId,
userId,
},
include: {
result: {
select: {
id: true,
summary: true,
wcagCompliance: true,
createdAt: true,
},
},
},
});
if (!job) {
return c.json({ error: 'Scan not found' }, 404);
}
return c.json(job);
});
// Get scan result
scanRoutes.get('/:id/result', async (c) => {
const userId = c.get('userId');
const scanId = c.req.param('id');
const result = await prisma.scanResult.findFirst({
where: {
jobId: scanId,
job: {
userId,
},
},
});
if (!result) {
return c.json({ error: 'Scan result not found' }, 404);
}
return c.json(result);
});
// Get scan violations
scanRoutes.get('/:id/violations', async (c) => {
const userId = c.get('userId');
const scanId = c.req.param('id');
const { impact, tag } = c.req.query();
const result = await prisma.scanResult.findFirst({
where: {
jobId: scanId,
job: {
userId,
},
},
select: {
violations: true,
},
});
if (!result) {
return c.json({ error: 'Scan result not found' }, 404);
}
let violations = result.violations as any[];
// Filter by impact if provided
if (impact) {
violations = violations.filter(v => v.impact === impact);
}
// Filter by tag if provided
if (tag) {
violations = violations.filter(v => v.tags.includes(tag));
}
return c.json({ violations });
});
// Cancel a pending scan
scanRoutes.delete('/:id', async (c) => {
const userId = c.get('userId');
const scanId = c.req.param('id');
const job = await prisma.scanJob.findFirst({
where: {
id: scanId,
userId,
status: 'PENDING',
},
});
if (!job) {
return c.json({ error: 'Scan not found or cannot be cancelled' }, 404);
}
// Update job status
await prisma.scanJob.update({
where: { id: scanId },
data: {
status: 'FAILED',
error: 'Cancelled by user',
completedAt: new Date(),
},
});
// Remove from queue if possible
const queueJob = await scanQueue.getJob(scanId);
if (queueJob) {
await queueJob.remove();
}
return c.json({ message: 'Scan cancelled successfully' });
});
// Get user's scan history
scanRoutes.get('/', async (c) => {
const userId = c.get('userId');
const { page = '1', limit = '20', status, websiteId } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
const where = {
userId,
...(status && { status }),
...(websiteId && { websiteId }),
};
const [jobs, total] = await Promise.all([
prisma.scanJob.findMany({
where,
skip,
take: limitNum,
orderBy: { createdAt: 'desc' },
include: {
website: {
select: {
id: true,
name: true,
url: true,
},
},
result: {
select: {
id: true,
summary: true,
},
},
},
}),
prisma.scanJob.count({ where }),
]);
return c.json({
scans: jobs,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
export { scanRoutes };

View file

@ -0,0 +1,245 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { prisma } from '../utils/prisma';
import { authenticate } from '../middleware/auth';
import type { Website, ScanSchedule } from '@wcag-ada/shared';
const websiteRoutes = new Hono();
// Apply authentication to all routes
websiteRoutes.use('*', authenticate);
// Validation schemas
const createWebsiteSchema = z.object({
name: z.string().min(1),
url: z.string().url(),
tags: z.array(z.string()).optional(),
scanSchedule: z.object({
frequency: z.enum(['manual', 'hourly', 'daily', 'weekly', 'monthly']),
dayOfWeek: z.number().min(0).max(6).optional(),
dayOfMonth: z.number().min(1).max(31).optional(),
hour: z.number().min(0).max(23).optional(),
timezone: z.string().optional(),
}).optional(),
authConfig: z.any().optional(),
scanOptions: z.any().optional(),
});
const updateWebsiteSchema = createWebsiteSchema.partial();
// List websites
websiteRoutes.get('/', async (c) => {
const userId = c.get('userId');
const { page = '1', limit = '20', search } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
const where = {
userId,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ url: { contains: search, mode: 'insensitive' } },
],
}),
};
const [websites, total] = await Promise.all([
prisma.website.findMany({
where,
skip,
take: limitNum,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { scanResults: true },
},
},
}),
prisma.website.count({ where }),
]);
return c.json({
websites,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
// Get website by ID
websiteRoutes.get('/:id', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
include: {
scanResults: {
take: 1,
orderBy: { createdAt: 'desc' },
},
_count: {
select: {
scanResults: true,
scanJobs: true,
},
},
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
return c.json(website);
});
// Create website
websiteRoutes.post('/', zValidator('json', createWebsiteSchema), async (c) => {
const userId = c.get('userId');
const data = c.req.valid('json');
// Check if website already exists for this user
const existing = await prisma.website.findFirst({
where: {
url: data.url,
userId,
},
});
if (existing) {
return c.json({ error: 'Website already exists' }, 400);
}
const website = await prisma.website.create({
data: {
...data,
userId,
scanSchedule: data.scanSchedule ? data.scanSchedule : undefined,
authConfig: data.authConfig ? data.authConfig : undefined,
scanOptions: data.scanOptions ? data.scanOptions : undefined,
},
});
return c.json(website, 201);
});
// Update website
websiteRoutes.patch('/:id', zValidator('json', updateWebsiteSchema), async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
const data = c.req.valid('json');
// Check ownership
const existing = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!existing) {
return c.json({ error: 'Website not found' }, 404);
}
const website = await prisma.website.update({
where: { id: websiteId },
data: {
...data,
scanSchedule: data.scanSchedule !== undefined ? data.scanSchedule : undefined,
authConfig: data.authConfig !== undefined ? data.authConfig : undefined,
scanOptions: data.scanOptions !== undefined ? data.scanOptions : undefined,
},
});
return c.json(website);
});
// Delete website
websiteRoutes.delete('/:id', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
// Check ownership
const existing = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!existing) {
return c.json({ error: 'Website not found' }, 404);
}
// Soft delete by setting active to false
await prisma.website.update({
where: { id: websiteId },
data: { active: false },
});
return c.json({ message: 'Website deleted successfully' });
});
// Get website scan history
websiteRoutes.get('/:id/scans', async (c) => {
const userId = c.get('userId');
const websiteId = c.req.param('id');
const { page = '1', limit = '20' } = c.req.query();
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const skip = (pageNum - 1) * limitNum;
// Check ownership
const website = await prisma.website.findFirst({
where: {
id: websiteId,
userId,
},
});
if (!website) {
return c.json({ error: 'Website not found' }, 404);
}
const [scans, total] = await Promise.all([
prisma.scanResult.findMany({
where: { websiteId },
skip,
take: limitNum,
orderBy: { createdAt: 'desc' },
select: {
id: true,
url: true,
scanDuration: true,
summary: true,
wcagCompliance: true,
createdAt: true,
},
}),
prisma.scanResult.count({ where: { websiteId } }),
]);
return c.json({
scans,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
});
export { websiteRoutes };

View file

@ -0,0 +1,271 @@
import type { Website, ScanResult, Report } from '@prisma/client';
import type { ReportPeriod, ReportSummary } from '@wcag-ada/shared';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { appConfig } from '../config';
import { getStorageConfig } from '@wcag-ada/config';
import { nanoid } from 'nanoid';
interface GenerateReportOptions {
website: Website;
scanResults: ScanResult[];
type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND';
format: 'PDF' | 'HTML' | 'JSON' | 'CSV';
period: ReportPeriod;
}
interface GenerateReportResult {
summary: ReportSummary;
data: any;
fileUrl?: string;
}
export async function generateReport(
options: GenerateReportOptions
): Promise<GenerateReportResult> {
const { website, scanResults, type, format, period } = options;
// Calculate summary statistics
const summary = calculateReportSummary(scanResults, period);
// Generate report data based on type
let data: any;
switch (type) {
case 'COMPLIANCE':
data = generateComplianceData(website, scanResults, summary);
break;
case 'EXECUTIVE':
data = generateExecutiveData(website, scanResults, summary);
break;
case 'TECHNICAL':
data = generateTechnicalData(website, scanResults, summary);
break;
case 'TREND':
data = generateTrendData(website, scanResults, summary);
break;
}
// Generate file if not JSON format
let fileUrl: string | undefined;
if (format !== 'JSON') {
fileUrl = await generateReportFile(data, format, type);
}
return {
summary,
data,
fileUrl,
};
}
function calculateReportSummary(
scanResults: ScanResult[],
period: ReportPeriod
): ReportSummary {
if (scanResults.length === 0) {
return {
averageScore: 0,
totalScans: 0,
improvementRate: 0,
criticalIssuesFixed: 0,
newIssuesFound: 0,
complianceLevel: { level: 'AA', version: '2.1' },
};
}
// Calculate average score
const scores = scanResults.map(r => (r.summary as any).score || 0);
const averageScore = scores.reduce((a, b) => a + b, 0) / scores.length;
// Calculate improvement rate
const firstScore = scores[0];
const lastScore = scores[scores.length - 1];
const improvementRate = firstScore > 0 ? ((lastScore - firstScore) / firstScore) * 100 : 0;
// Count issues fixed and new issues
let criticalIssuesFixed = 0;
let newIssuesFound = 0;
if (scanResults.length >= 2) {
const firstScan = scanResults[0];
const lastScan = scanResults[scanResults.length - 1];
const firstCritical = (firstScan.summary as any).criticalIssues || 0;
const lastCritical = (lastScan.summary as any).criticalIssues || 0;
criticalIssuesFixed = Math.max(0, firstCritical - lastCritical);
newIssuesFound = Math.max(0, lastCritical - firstCritical);
}
// Get compliance level from last scan
const lastScan = scanResults[scanResults.length - 1];
const complianceLevel = (lastScan.wcagCompliance as any)?.level || { level: 'AA', version: '2.1' };
return {
averageScore: Math.round(averageScore * 100) / 100,
totalScans: scanResults.length,
improvementRate: Math.round(improvementRate * 100) / 100,
criticalIssuesFixed,
newIssuesFound,
complianceLevel,
};
}
function generateComplianceData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
return {
website: {
name: website.name,
url: website.url,
},
summary,
latestScan: scanResults[scanResults.length - 1],
complianceHistory: scanResults.map(r => ({
date: r.createdAt,
score: (r.summary as any).score,
compliant: (r.wcagCompliance as any).isCompliant,
})),
};
}
function generateExecutiveData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
return {
website: {
name: website.name,
url: website.url,
},
summary,
keyMetrics: {
currentScore: (scanResults[scanResults.length - 1].summary as any).score,
improvement: summary.improvementRate,
totalIssues: (scanResults[scanResults.length - 1].summary as any).violationCount,
criticalIssues: (scanResults[scanResults.length - 1].summary as any).criticalIssues,
},
recommendations: generateRecommendations(scanResults[scanResults.length - 1]),
};
}
function generateTechnicalData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
const latestScan = scanResults[scanResults.length - 1];
return {
website: {
name: website.name,
url: website.url,
},
summary,
violations: latestScan.violations,
topIssues: getTopIssues(latestScan.violations as any[]),
fixPriority: generateFixPriority(latestScan.violations as any[]),
};
}
function generateTrendData(
website: Website,
scanResults: ScanResult[],
summary: ReportSummary
): any {
return {
website: {
name: website.name,
url: website.url,
},
summary,
trends: scanResults.map(r => ({
date: r.createdAt,
score: (r.summary as any).score,
violations: (r.summary as any).violationCount,
critical: (r.summary as any).criticalIssues,
serious: (r.summary as any).seriousIssues,
moderate: (r.summary as any).moderateIssues,
minor: (r.summary as any).minorIssues,
})),
};
}
function generateRecommendations(scanResult: ScanResult): string[] {
const violations = scanResult.violations as any[];
const recommendations: string[] = [];
// Group violations by type and generate recommendations
const criticalViolations = violations.filter(v => v.impact === 'critical');
if (criticalViolations.length > 0) {
recommendations.push('Address critical accessibility violations immediately to reduce legal risk');
}
// Add more intelligent recommendations based on violation patterns
return recommendations;
}
function getTopIssues(violations: any[]): any[] {
// Sort by impact and number of occurrences
return violations
.sort((a, b) => {
const impactOrder = { critical: 4, serious: 3, moderate: 2, minor: 1 };
const aScore = impactOrder[a.impact] * a.nodes.length;
const bScore = impactOrder[b.impact] * b.nodes.length;
return bScore - aScore;
})
.slice(0, 10);
}
function generateFixPriority(violations: any[]): any[] {
// Generate prioritized fix list
return violations
.map(v => ({
id: v.id,
impact: v.impact,
occurrences: v.nodes.length,
estimatedTime: estimateFixTime(v),
priority: calculatePriority(v),
}))
.sort((a, b) => b.priority - a.priority);
}
function estimateFixTime(violation: any): number {
const baseTime = {
critical: 30,
serious: 20,
moderate: 15,
minor: 10,
};
return (baseTime[violation.impact] || 15) * violation.nodes.length;
}
function calculatePriority(violation: any): number {
const impactScore = { critical: 100, serious: 75, moderate: 50, minor: 25 };
return (impactScore[violation.impact] || 50) * Math.log(violation.nodes.length + 1);
}
async function generateReportFile(
data: any,
format: 'PDF' | 'HTML' | 'CSV',
type: string
): Promise<string> {
const storageConfig = getStorageConfig();
const reportPath = join(storageConfig.local.basePath, storageConfig.local.reports);
// Create report directory if it doesn't exist
await mkdir(reportPath, { recursive: true });
const filename = `${type.toLowerCase()}_${nanoid()}.${format.toLowerCase()}`;
const filepath = join(reportPath, filename);
// For now, just save as JSON
// In production, you would generate actual PDF/HTML/CSV files
await writeFile(filepath, JSON.stringify(data, null, 2));
// Return relative URL (in production, this would be a CDN URL)
return `/reports/${filename}`;
}

View file

@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { appConfig } from '../config';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: appConfig.environment === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (appConfig.environment !== 'production') globalForPrisma.prisma = prisma;

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"lib": ["ES2022"],
"types": ["bun-types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "../shared" },
{ "path": "../scanner" }
]
}

View file

@ -0,0 +1,181 @@
# WCAG-ADA Configuration
Centralized configuration management for the WCAG-ADA compliance platform, built on top of the core configuration system.
## Overview
This configuration system provides:
- Type-safe configuration with Zod schemas
- Multi-source configuration (JSON files + environment variables)
- Environment-specific overrides
- Service-specific configurations
- Feature flags management
## Usage
### Basic Usage
```typescript
import { initializeWcagConfig, getWcagConfig } from '@wcag-ada/config';
// Initialize configuration (do this once at app startup)
const config = initializeWcagConfig('api'); // 'api' | 'dashboard' | 'worker'
// Access configuration values
console.log(config.services.api.port); // 3001
console.log(config.scanner.concurrency); // 2
```
### Helper Functions
```typescript
import {
getScannerConfig,
getWorkerConfig,
isFeatureEnabled,
getServiceConfig
} from '@wcag-ada/config';
// Get specific configurations
const scannerConfig = getScannerConfig();
const workerConfig = getWorkerConfig();
// Check feature flags
if (isFeatureEnabled('screenshots')) {
// Screenshot feature is enabled
}
if (isFeatureEnabled('reports.pdf')) {
// PDF reports are enabled
}
// Get service configuration
const apiConfig = getServiceConfig('api');
```
## Configuration Structure
### Scanner Configuration
- `concurrency`: Number of concurrent scans (1-10)
- `timeout`: Maximum scan duration
- `headless`: Run browser in headless mode
- `blockResources`: Block images/fonts for faster scans
- `viewport`: Default viewport dimensions
### Worker Configuration
- `concurrency`: Number of concurrent jobs
- `queueName`: BullMQ queue name
- `redis`: Redis connection settings
- `jobs`: Job-specific configurations
- `scheduler`: Scheduled job settings
### Features Configuration
- `screenshots`: Screenshot capture settings
- `customRules`: Custom accessibility rules
- `multiPage`: Multi-page scanning
- `reports`: Report generation features
- `integrations`: External service integrations
- `enterprise`: Enterprise features (SSO, white-label)
### Providers Configuration
- `storage`: File storage (local, S3, GCS, Azure)
- `email`: Email providers (SMTP, SendGrid, SES)
- `auth`: Authentication settings (JWT, OAuth)
- `analytics`: Analytics providers
- `cdn`: CDN configuration
## Environment Variables
The system supports environment variable overrides with the `WCAG_` prefix:
```bash
# Scanner settings
WCAG_SCANNER_CONCURRENCY=5
WCAG_SCANNER_TIMEOUT=180000
WCAG_SCANNER_HEADLESS=true
# Worker settings
WCAG_WORKER_CONCURRENCY=3
WCAG_WORKER_REDIS_HOST=redis.example.com
WCAG_WORKER_REDIS_PORT=6379
# Service ports
WCAG_SERVICES_API_PORT=3001
WCAG_SERVICES_DASHBOARD_PORT=3000
# Storage
WCAG_PROVIDERS_STORAGE_TYPE=s3
WCAG_PROVIDERS_STORAGE_S3_BUCKET=wcag-ada-storage
WCAG_PROVIDERS_STORAGE_S3_REGION=us-east-1
# Authentication
WCAG_PROVIDERS_AUTH_JWT_SECRET=your-secret-key
```
## Configuration Files
Configuration files are loaded in this order (later files override earlier ones):
1. `config/default.json` - Base configuration
2. `config/{environment}.json` - Environment-specific (development, production, test)
3. Environment variables - Highest priority
## Service-Specific Configuration
Each service can have its own configuration:
```typescript
// API Service
initializeWcagConfig('api');
// Uses services.api configuration
// Dashboard
initializeWcagConfig('dashboard');
// Uses services.dashboard configuration
// Worker
initializeWcagConfig('worker');
// Uses services.worker configuration
```
## Adding New Configuration
1. Add schema definition in `src/schemas/`
2. Update `wcag-app.schema.ts` to include new schema
3. Add default values in `config/default.json`
4. Add environment-specific overrides as needed
5. Create helper functions in `src/index.ts` for easy access
## Examples
### Check Subscription Limits
```typescript
import { getSubscriptionConfig } from '@wcag-ada/config';
const starterLimits = getSubscriptionConfig('starter');
console.log(starterLimits.websites); // 5
console.log(starterLimits.scansPerMonth); // 500
```
### Get Compliance Settings
```typescript
import { getComplianceConfig } from '@wcag-ada/config';
const compliance = getComplianceConfig();
console.log(compliance.defaultLevel); // { standard: 'WCAG21', level: 'AA' }
console.log(compliance.criticalCriteria); // ['1.1.1', '1.3.1', ...]
```
### Storage Configuration
```typescript
import { getStorageConfig } from '@wcag-ada/config';
const storage = getStorageConfig();
if (storage.type === 's3') {
// Use S3 storage
} else {
// Use local storage
}
```

View file

@ -0,0 +1,117 @@
{
"appName": "wcag-ada",
"environment": "default",
"log": {
"level": "info",
"format": "json",
"pretty": false
},
"database": {
"postgres": {
"host": "localhost",
"port": 5432,
"database": "wcag_ada",
"connectionLimit": 10
},
"redis": {
"host": "localhost",
"port": 6379,
"db": 0
}
},
"scanner": {
"concurrency": 2,
"timeout": 120000,
"pageLoadTimeout": 30000,
"headless": true,
"blockResources": true,
"viewport": {
"width": 1280,
"height": 720,
"deviceScaleFactor": 1
}
},
"worker": {
"enabled": true,
"concurrency": 2,
"queueName": "accessibility-scans",
"redis": {
"host": "localhost",
"port": 6379,
"db": 2
}
},
"features": {
"screenshots": {
"enabled": true,
"quality": 80
},
"customRules": {
"enabled": true
},
"reports": {
"pdf": {
"enabled": true
}
}
},
"providers": {
"storage": {
"type": "local",
"local": {
"basePath": "/tmp/wcag-ada"
}
},
"email": {
"enabled": true,
"provider": "smtp"
},
"auth": {
"jwt": {
"expiresIn": "7d"
}
}
},
"services": {
"api": {
"name": "wcag-api",
"port": 3001,
"cors": {
"enabled": true,
"origin": "*"
},
"rateLimit": {
"enabled": true,
"windowMs": 900000,
"max": 100
}
},
"dashboard": {
"name": "wcag-dashboard",
"port": 3000,
"apiUrl": "http://localhost:3001"
},
"worker": {
"name": "wcag-worker",
"port": 3002
}
},
"compliance": {
"defaultLevel": {
"standard": "WCAG21",
"level": "AA"
}
},
"subscriptions": {
"enabled": true
}
}

View file

@ -0,0 +1,51 @@
{
"environment": "development",
"log": {
"level": "debug",
"pretty": true
},
"database": {
"postgres": {
"host": "localhost",
"database": "wcag_ada_dev"
}
},
"scanner": {
"headless": false,
"timeout": 300000
},
"worker": {
"concurrency": 1
},
"features": {
"reports": {
"pdf": {
"watermark": false
}
},
"enterprise": {
"audit": {
"enabled": false
}
}
},
"providers": {
"auth": {
"jwt": {
"secret": "dev-secret-change-me"
}
}
},
"services": {
"dashboard": {
"apiUrl": "http://localhost:3001"
}
}
}

View file

@ -0,0 +1,64 @@
{
"environment": "production",
"log": {
"level": "warn",
"pretty": false
},
"scanner": {
"concurrency": 5,
"headless": true
},
"worker": {
"concurrency": 4,
"redis": {
"maxRetriesPerRequest": 3
}
},
"features": {
"screenshots": {
"quality": 70
},
"reports": {
"pdf": {
"watermark": true
}
},
"enterprise": {
"audit": {
"enabled": true,
"retention": 365
}
}
},
"providers": {
"storage": {
"type": "s3"
},
"email": {
"provider": "sendgrid"
},
"cdn": {
"enabled": true
}
},
"services": {
"api": {
"cors": {
"origin": "https://app.wcag-ada.com"
},
"rateLimit": {
"max": 1000
}
},
"dashboard": {
"apiUrl": "https://api.wcag-ada.com",
"publicUrl": "https://app.wcag-ada.com"
}
}
}

View file

@ -0,0 +1,53 @@
{
"environment": "test",
"log": {
"level": "error",
"pretty": false
},
"database": {
"postgres": {
"database": "wcag_ada_test"
},
"redis": {
"db": 15
}
},
"scanner": {
"concurrency": 1,
"timeout": 10000,
"headless": true
},
"worker": {
"enabled": false
},
"features": {
"screenshots": {
"enabled": false
},
"webhooks": {
"enabled": false
}
},
"providers": {
"email": {
"enabled": false
},
"analytics": {
"enabled": false
}
},
"services": {
"api": {
"rateLimit": {
"enabled": false
}
}
}
}

View file

@ -0,0 +1,19 @@
{
"name": "@wcag-ada/config",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b",
"dev": "tsc -w",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@stock-bot/core-config": "workspace:*",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.5",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,67 @@
import { ConfigManager, createAppConfig } from '@stock-bot/core-config';
import { wcagAppConfigSchema, type WcagAppConfig } from './schemas/wcag-app.schema';
import * as path from 'path';
let configInstance: ConfigManager<WcagAppConfig> | null = null;
/**
* Initialize the WCAG-ADA configuration
* @param serviceName - Optional service name for service-specific overrides
*/
export function initializeWcagConfig(
serviceName?: 'api' | 'dashboard' | 'worker'
): WcagAppConfig {
if (!configInstance) {
configInstance = createAppConfig(wcagAppConfigSchema, {
configPath: path.join(__dirname, '../config'),
envPrefix: 'WCAG_',
});
}
const config = configInstance.initialize(wcagAppConfigSchema);
// Apply service-specific overrides if provided
if (serviceName && config.services[serviceName]) {
const serviceConfig = config.services[serviceName];
// Override port if specified for the service
if (serviceConfig.port && process.env.PORT) {
serviceConfig.port = parseInt(process.env.PORT, 10);
}
}
return config;
}
/**
* Get the current WCAG configuration instance
* @throws Error if configuration hasn't been initialized
*/
export function getWcagConfig(): WcagAppConfig {
if (!configInstance) {
throw new Error('WCAG configuration not initialized. Call initializeWcagConfig() first.');
}
return configInstance.get();
}
/**
* Get a specific configuration value by path
* @param path - Dot-notation path to the configuration value
*/
export function getConfigValue<T = unknown>(path: string): T {
if (!configInstance) {
throw new Error('WCAG configuration not initialized. Call initializeWcagConfig() first.');
}
return configInstance.getValue<T>(path);
}
/**
* Check if a configuration path exists
* @param path - Dot-notation path to check
*/
export function hasConfigValue(path: string): boolean {
if (!configInstance) {
return false;
}
return configInstance.has(path);
}

View file

@ -0,0 +1,106 @@
// Main configuration exports
export {
initializeWcagConfig,
getWcagConfig,
getConfigValue,
hasConfigValue,
} from './config-instance';
// Schema exports
export { wcagAppConfigSchema, type WcagAppConfig } from './schemas/wcag-app.schema';
export { scannerConfigSchema, type ScannerConfig } from './schemas/scanner.schema';
export { workerConfigSchema, type WorkerConfig } from './schemas/worker.schema';
export { featuresConfigSchema, type FeaturesConfig } from './schemas/features.schema';
export { providersConfigSchema, type ProvidersConfig } from './schemas/providers.schema';
// Helper functions
import { getWcagConfig } from './config-instance';
import type { ScannerConfig, WorkerConfig, FeaturesConfig, ProvidersConfig } from './schemas';
/**
* Get scanner configuration
*/
export function getScannerConfig(): ScannerConfig {
return getWcagConfig().scanner;
}
/**
* Get worker configuration
*/
export function getWorkerConfig(): WorkerConfig {
return getWcagConfig().worker;
}
/**
* Get features configuration
*/
export function getFeaturesConfig(): FeaturesConfig {
return getWcagConfig().features;
}
/**
* Get providers configuration
*/
export function getProvidersConfig(): ProvidersConfig {
return getWcagConfig().providers;
}
/**
* Get service configuration
*/
export function getServiceConfig(service: 'api' | 'dashboard' | 'worker') {
return getWcagConfig().services[service];
}
/**
* Check if a feature is enabled
*/
export function isFeatureEnabled(feature: string): boolean {
const features = getFeaturesConfig();
const parts = feature.split('.');
let current: any = features;
for (const part of parts) {
if (typeof current !== 'object' || !(part in current)) {
return false;
}
current = current[part];
}
return current === true || (typeof current === 'object' && current.enabled === true);
}
/**
* Get database configuration
*/
export function getDatabaseConfig() {
return getWcagConfig().database;
}
/**
* Get Redis configuration for queues
*/
export function getRedisConfig() {
return getWcagConfig().worker.redis;
}
/**
* Get storage configuration
*/
export function getStorageConfig() {
return getWcagConfig().providers.storage;
}
/**
* Get compliance configuration
*/
export function getComplianceConfig() {
return getWcagConfig().compliance;
}
/**
* Get subscription tier configuration
*/
export function getSubscriptionConfig(tier: 'starter' | 'professional' | 'enterprise') {
return getWcagConfig().subscriptions.tiers[tier];
}

View file

@ -0,0 +1,89 @@
import { z } from 'zod';
export const featuresConfigSchema = z.object({
// Scanner features
screenshots: z.object({
enabled: z.boolean().default(true),
quality: z.number().min(0).max(100).default(80),
fullPage: z.boolean().default(false),
}).default({}),
customRules: z.object({
enabled: z.boolean().default(true),
maxRules: z.number().default(50),
}).default({}),
multiPage: z.object({
enabled: z.boolean().default(true),
maxPages: z.number().default(10),
maxDepth: z.number().default(3),
}).default({}),
// API features
apiKeys: z.object({
enabled: z.boolean().default(true),
maxPerUser: z.number().default(5),
}).default({}),
webhooks: z.object({
enabled: z.boolean().default(true),
maxPerWebsite: z.number().default(3),
retryAttempts: z.number().default(3),
}).default({}),
// Report features
reports: z.object({
pdf: z.object({
enabled: z.boolean().default(true),
watermark: z.boolean().default(true),
}).default({}),
excel: z.object({
enabled: z.boolean().default(false),
}).default({}),
scheduling: z.object({
enabled: z.boolean().default(true),
}).default({}),
}).default({}),
// Compliance features
compliance: z.object({
wcag20: z.boolean().default(true),
wcag21: z.boolean().default(true),
wcag22: z.boolean().default(true),
section508: z.boolean().default(false),
ada: z.boolean().default(true),
}).default({}),
// Integration features
integrations: z.object({
github: z.object({
enabled: z.boolean().default(true),
}).default({}),
slack: z.object({
enabled: z.boolean().default(true),
}).default({}),
teams: z.object({
enabled: z.boolean().default(false),
}).default({}),
jira: z.object({
enabled: z.boolean().default(false),
}).default({}),
}).default({}),
// Enterprise features
enterprise: z.object({
sso: z.object({
enabled: z.boolean().default(false),
providers: z.array(z.enum(['saml', 'oauth', 'ldap'])).default([]),
}).default({}),
whiteLabel: z.object({
enabled: z.boolean().default(false),
}).default({}),
audit: z.object({
enabled: z.boolean().default(true),
retention: z.number().default(365), // days
}).default({}),
}).default({}),
});
export type FeaturesConfig = z.infer<typeof featuresConfigSchema>;

View file

@ -0,0 +1,84 @@
import { z } from 'zod';
export const providersConfigSchema = z.object({
// Storage providers
storage: z.object({
type: z.enum(['local', 's3', 'gcs', 'azure']).default('local'),
local: z.object({
basePath: z.string().default('/tmp/wcag-ada'),
reports: z.string().default('reports'),
screenshots: z.string().default('screenshots'),
exports: z.string().default('exports'),
}).default({}),
s3: z.object({
enabled: z.boolean().default(false),
bucket: z.string().optional(),
region: z.string().default('us-east-1'),
accessKeyId: z.string().optional(),
secretAccessKey: z.string().optional(),
endpoint: z.string().optional(),
}).default({}),
}).default({}),
// Email providers
email: z.object({
enabled: z.boolean().default(true),
provider: z.enum(['smtp', 'sendgrid', 'ses', 'postmark']).default('smtp'),
from: z.object({
name: z.string().default('WCAG-ADA Compliance'),
email: z.string().email().default('noreply@wcag-ada.com'),
}).default({}),
smtp: z.object({
host: z.string().default('localhost'),
port: z.number().default(587),
secure: z.boolean().default(false),
auth: z.object({
user: z.string().optional(),
pass: z.string().optional(),
}).default({}),
}).default({}),
sendgrid: z.object({
apiKey: z.string().optional(),
}).default({}),
}).default({}),
// Authentication providers
auth: z.object({
jwt: z.object({
secret: z.string().default('change-me-in-production'),
expiresIn: z.string().default('7d'),
refreshExpiresIn: z.string().default('30d'),
}).default({}),
oauth: z.object({
google: z.object({
enabled: z.boolean().default(false),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
}).default({}),
github: z.object({
enabled: z.boolean().default(false),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
}).default({}),
}).default({}),
}).default({}),
// Analytics providers
analytics: z.object({
enabled: z.boolean().default(true),
provider: z.enum(['posthog', 'mixpanel', 'amplitude', 'custom']).default('posthog'),
posthog: z.object({
apiKey: z.string().optional(),
host: z.string().default('https://app.posthog.com'),
}).default({}),
}).default({}),
// CDN providers
cdn: z.object({
enabled: z.boolean().default(false),
provider: z.enum(['cloudflare', 'cloudfront', 'fastly']).default('cloudflare'),
baseUrl: z.string().optional(),
}).default({}),
});
export type ProvidersConfig = z.infer<typeof providersConfigSchema>;

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
export const scannerConfigSchema = z.object({
concurrency: z.number().min(1).max(10).default(2),
timeout: z.number().min(30000).default(120000), // 2 minutes default
pageLoadTimeout: z.number().min(10000).default(30000),
headless: z.boolean().default(true),
blockResources: z.boolean().default(true),
viewport: z.object({
width: z.number().default(1280),
height: z.number().default(720),
deviceScaleFactor: z.number().default(1),
}).default({}),
browsers: z.object({
chromium: z.object({
enabled: z.boolean().default(true),
args: z.array(z.string()).default(['--no-sandbox', '--disable-setuid-sandbox']),
}).default({}),
}).default({}),
axe: z.object({
tags: z.array(z.string()).default(['wcag2aa', 'wcag21aa']),
resultTypes: z.array(z.enum(['violations', 'passes', 'incomplete', 'inapplicable']))
.default(['violations', 'passes', 'incomplete', 'inapplicable']),
}).default({}),
retries: z.object({
maxAttempts: z.number().default(3),
backoff: z.object({
type: z.enum(['exponential', 'fixed']).default('exponential'),
delay: z.number().default(2000),
}).default({}),
}).default({}),
});
export type ScannerConfig = z.infer<typeof scannerConfigSchema>;

View file

@ -0,0 +1,111 @@
import { z } from 'zod';
import {
baseAppConfigSchema,
logConfigSchema,
databaseConfigSchema,
serviceConfigSchema,
} from '@stock-bot/core-config';
import { scannerConfigSchema } from './scanner.schema';
import { workerConfigSchema } from './worker.schema';
import { featuresConfigSchema } from './features.schema';
import { providersConfigSchema } from './providers.schema';
// Service-specific configurations
const wcagServicesSchema = z.object({
api: serviceConfigSchema.extend({
port: z.number().default(3001),
cors: z.object({
enabled: z.boolean().default(true),
origin: z.string().default('*'),
credentials: z.boolean().default(true),
}).default({}),
rateLimit: z.object({
enabled: z.boolean().default(true),
windowMs: z.number().default(900000), // 15 minutes
max: z.number().default(100),
keyGenerator: z.enum(['ip', 'userId', 'apiKey']).default('userId'),
}).default({}),
pagination: z.object({
defaultLimit: z.number().default(20),
maxLimit: z.number().default(100),
}).default({}),
}),
dashboard: serviceConfigSchema.extend({
port: z.number().default(3000),
apiUrl: z.string().default('http://localhost:3001'),
publicUrl: z.string().default('http://localhost:3000'),
}),
worker: serviceConfigSchema.extend({
port: z.number().default(3002), // For health checks
}),
});
// Main WCAG application configuration schema
export const wcagAppConfigSchema = baseAppConfigSchema.extend({
appName: z.literal('wcag-ada').default('wcag-ada'),
// Core configurations
log: logConfigSchema,
database: databaseConfigSchema,
// WCAG-specific configurations
scanner: scannerConfigSchema,
worker: workerConfigSchema,
features: featuresConfigSchema,
providers: providersConfigSchema,
// Service configurations
services: wcagServicesSchema.default({}),
// Business logic configurations
compliance: z.object({
defaultLevel: z.object({
standard: z.enum(['WCAG20', 'WCAG21', 'WCAG22']).default('WCAG21'),
level: z.enum(['A', 'AA', 'AAA']).default('AA'),
}).default({}),
passingScore: z.object({
A: z.number().min(0).max(100).default(95),
AA: z.number().min(0).max(100).default(98),
AAA: z.number().min(0).max(100).default(100),
}).default({}),
criticalCriteria: z.array(z.string()).default([
'1.1.1', // Non-text Content
'1.3.1', // Info and Relationships
'1.4.3', // Contrast (Minimum)
'2.1.1', // Keyboard
'2.1.2', // No Keyboard Trap
'2.4.1', // Bypass Blocks
'2.4.2', // Page Titled
'4.1.2', // Name, Role, Value
]),
}).default({}),
// Subscription/pricing tiers
subscriptions: z.object({
enabled: z.boolean().default(true),
tiers: z.object({
starter: z.object({
websites: z.number().default(5),
scansPerMonth: z.number().default(500),
users: z.number().default(1),
price: z.number().default(49),
}).default({}),
professional: z.object({
websites: z.number().default(25),
scansPerMonth: z.number().default(5000),
users: z.number().default(5),
price: z.number().default(149),
}).default({}),
enterprise: z.object({
websites: z.number().default(-1), // Unlimited
scansPerMonth: z.number().default(-1),
users: z.number().default(-1),
price: z.number().default(499),
}).default({}),
}).default({}),
}).default({}),
});
export type WcagAppConfig = z.infer<typeof wcagAppConfigSchema>;

View file

@ -0,0 +1,49 @@
import { z } from 'zod';
export const workerConfigSchema = z.object({
enabled: z.boolean().default(true),
concurrency: z.number().min(1).max(10).default(2),
queueName: z.string().default('accessibility-scans'),
redis: z.object({
host: z.string().default('localhost'),
port: z.number().default(6379),
password: z.string().optional(),
db: z.number().default(2), // Different DB for WCAG
maxRetriesPerRequest: z.number().nullable().default(null),
}).default({}),
jobs: z.object({
scan: z.object({
priority: z.number().default(0),
attempts: z.number().default(3),
backoff: z.object({
type: z.enum(['exponential', 'fixed']).default('exponential'),
delay: z.number().default(2000),
}).default({}),
timeout: z.number().default(300000), // 5 minutes
removeOnComplete: z.object({
age: z.number().default(86400), // 24 hours
count: z.number().default(100),
}).default({}),
removeOnFail: z.object({
age: z.number().default(604800), // 7 days
count: z.number().default(500),
}).default({}),
}).default({}),
report: z.object({
priority: z.number().default(1),
attempts: z.number().default(2),
backoff: z.object({
type: z.enum(['exponential', 'fixed']).default('exponential'),
delay: z.number().default(5000),
}).default({}),
timeout: z.number().default(600000), // 10 minutes
}).default({}),
}).default({}),
scheduler: z.object({
enabled: z.boolean().default(true),
interval: z.number().default(60000), // Check every minute
timezone: z.string().default('UTC'),
}).default({}),
});
export type WorkerConfig = z.infer<typeof workerConfigSchema>;

View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "../../../libs/core/config" }
]
}

View file

@ -0,0 +1,57 @@
# Build stage
FROM oven/bun:1-alpine as builder
WORKDIR /app
# Copy workspace files
COPY package.json bun.lockb ./
COPY apps/wcag-ada/dashboard/package.json ./apps/wcag-ada/dashboard/
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY apps/wcag-ada/dashboard ./apps/wcag-ada/dashboard
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
COPY tsconfig.json ./
# Build the application
WORKDIR /app/apps/wcag-ada/dashboard
RUN bun run build
# Production stage with nginx
FROM nginx:alpine
# Install runtime dependencies
RUN apk add --no-cache curl
# Copy nginx configuration
COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf
# Copy built application
COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set ownership
RUN chown -R nodejs:nodejs /usr/share/nginx/html && \
chown -R nodejs:nodejs /var/cache/nginx && \
chown -R nodejs:nodejs /var/log/nginx && \
chown -R nodejs:nodejs /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R nodejs:nodejs /var/run/nginx.pid
USER nodejs
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WCAG-ADA Compliance Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,79 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Serve index.html for all routes (React Router)
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# API proxy (if needed in production)
location /api {
proxy_pass http://wcag-ada-api:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}

View file

@ -0,0 +1,61 @@
{
"name": "@wcag-ada/dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"zustand": "^4.4.7",
"@wcag-ada/shared": "workspace:*",
"@wcag-ada/config": "workspace:*",
"recharts": "^2.10.3",
"date-fns": "^3.0.6",
"clsx": "^2.1.0",
"lucide-react": "^0.303.0",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"tailwindcss-animate": "^1.0.7",
"react-hook-form": "^7.48.2",
"@hookform/resolvers": "^3.3.4",
"zod": "^3.22.4",
"class-variance-authority": "^0.7.0",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="#3B82F6"/>
<path d="M16 6C10.4772 6 6 10.4772 6 16C6 21.5228 10.4772 26 16 26C21.5228 26 26 21.5228 26 16C26 10.4772 21.5228 6 16 6ZM16 8C20.4183 8 24 11.5817 24 16C24 20.4183 20.4183 24 16 24C11.5817 24 8 20.4183 8 16C8 11.5817 11.5817 8 16 8Z" fill="white"/>
<path d="M16 10C12.6863 10 10 12.6863 10 16C10 19.3137 12.6863 22 16 22C19.3137 22 22 19.3137 22 16C22 12.6863 19.3137 10 16 10ZM13 15H15V17H13V15ZM17 15H19V17H17V15Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

View file

@ -0,0 +1,70 @@
import { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { apiClient } from '@/lib/api-client';
import { MainLayout } from '@/components/layout/main-layout';
import { AuthLayout } from '@/components/layout/auth-layout';
import { ProtectedRoute } from '@/components/layout/protected-route';
// Pages
import { LoginPage } from '@/pages/auth/login';
import { RegisterPage } from '@/pages/auth/register';
import { DashboardPage } from '@/pages/dashboard';
import { WebsitesPage } from '@/pages/websites';
import { WebsiteDetailPage } from '@/pages/websites/[id]';
import { ScansPage } from '@/pages/scans';
import { ScanDetailPage } from '@/pages/scans/[id]';
import { ReportsPage } from '@/pages/reports';
import { SettingsPage } from '@/pages/settings';
function App() {
const { setAuth, setLoading, token } = useAuthStore();
useEffect(() => {
const initAuth = async () => {
if (!token) {
setLoading(false);
return;
}
try {
const user = await apiClient.getMe();
setAuth(user, token);
} catch (error) {
console.error('Auth check failed:', error);
setLoading(false);
}
};
initAuth();
}, [token, setAuth, setLoading]);
return (
<Routes>
{/* Auth routes */}
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
{/* Protected routes */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/websites" element={<WebsitesPage />} />
<Route path="/websites/:id" element={<WebsiteDetailPage />} />
<Route path="/scans" element={<ScansPage />} />
<Route path="/scans/:id" element={<ScanDetailPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 404 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

View file

@ -0,0 +1,26 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
export function AuthLayout() {
const { isAuthenticated } = useAuthStore();
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
WCAG-ADA Compliance
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Automated accessibility testing and compliance monitoring
</p>
</div>
<Outlet />
</div>
</div>
);
}

View file

@ -0,0 +1,189 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useAuthStore } from '@/store/auth-store';
import {
Home,
Globe,
Scan,
FileText,
Settings,
LogOut,
Menu,
X,
Shield,
} from 'lucide-react';
import { useState } from 'react';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home },
{ name: 'Websites', href: '/websites', icon: Globe },
{ name: 'Scans', href: '/scans', icon: Scan },
{ name: 'Reports', href: '/reports', icon: FileText },
{ name: 'Settings', href: '/settings', icon: Settings },
];
export function MainLayout() {
const location = useLocation();
const { user, logout } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Mobile sidebar */}
<div
className={cn(
'fixed inset-0 z-50 lg:hidden',
sidebarOpen ? 'block' : 'hidden'
)}
>
<div
className="fixed inset-0 bg-gray-900/80"
onClick={() => setSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white dark:bg-gray-800">
<div className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<Shield className="h-8 w-8 text-primary" />
<span className="text-xl font-semibold">WCAG-ADA</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.href);
return (
<Link
key={item.name}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
onClick={() => setSidebarOpen(false)}
>
<Icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-medium">
{user?.name?.[0] || user?.email[0].toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || user?.email}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.role}
</p>
</div>
</div>
<Button
variant="outline"
className="w-full justify-start"
onClick={logout}
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
</div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-1 flex-col bg-white dark:bg-gray-800 border-r">
<div className="flex h-16 items-center px-6">
<div className="flex items-center gap-2">
<Shield className="h-8 w-8 text-primary" />
<span className="text-xl font-semibold">WCAG-ADA</span>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.href);
return (
<Link
key={item.name}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<Icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-medium">
{user?.name?.[0] || user?.email[0].toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || user?.email}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.role}
</p>
</div>
</div>
<Button
variant="outline"
className="w-full justify-start"
onClick={logout}
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
<div className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b bg-white dark:bg-gray-800 px-4 sm:px-6 lg:px-8">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-semibold">
{navigation.find((n) => location.pathname.startsWith(n.href))?.name || 'WCAG-ADA Compliance'}
</h1>
</div>
<main className="py-6">
<div className="px-4 sm:px-6 lg:px-8">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { Loader2 } from 'lucide-react';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View file

@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View file

@ -0,0 +1,167 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import { useAuthStore } from '@/store/auth-store';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
this.client.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor to handle errors
this.client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired or invalid
useAuthStore.getState().logout();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
// Auth endpoints
async login(email: string, password: string) {
const response = await this.client.post('/auth/login', { email, password });
return response.data;
}
async register(data: { email: string; password: string; name?: string; company?: string }) {
const response = await this.client.post('/auth/register', data);
return response.data;
}
async getMe() {
const response = await this.client.get('/auth/me');
return response.data;
}
async refreshApiKey() {
const response = await this.client.post('/auth/refresh-api-key');
return response.data;
}
// Website endpoints
async getWebsites(params?: { page?: number; limit?: number; search?: string }) {
const response = await this.client.get('/websites', { params });
return response.data;
}
async getWebsite(id: string) {
const response = await this.client.get(`/websites/${id}`);
return response.data;
}
async createWebsite(data: any) {
const response = await this.client.post('/websites', data);
return response.data;
}
async updateWebsite(id: string, data: any) {
const response = await this.client.patch(`/websites/${id}`, data);
return response.data;
}
async deleteWebsite(id: string) {
const response = await this.client.delete(`/websites/${id}`);
return response.data;
}
async getWebsiteScans(id: string, params?: { page?: number; limit?: number }) {
const response = await this.client.get(`/websites/${id}/scans`, { params });
return response.data;
}
// Scan endpoints
async createScan(data: { websiteId: string; url?: string; options?: any }) {
const response = await this.client.post('/scans', data);
return response.data;
}
async getScan(id: string) {
const response = await this.client.get(`/scans/${id}`);
return response.data;
}
async getScanResult(id: string) {
const response = await this.client.get(`/scans/${id}/result`);
return response.data;
}
async getScanViolations(id: string, params?: { impact?: string; tag?: string }) {
const response = await this.client.get(`/scans/${id}/violations`, { params });
return response.data;
}
async cancelScan(id: string) {
const response = await this.client.delete(`/scans/${id}`);
return response.data;
}
async getScans(params?: { page?: number; limit?: number; status?: string; websiteId?: string }) {
const response = await this.client.get('/scans', { params });
return response.data;
}
// Report endpoints
async generateReport(data: {
websiteId: string;
type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND';
format: 'PDF' | 'HTML' | 'JSON' | 'CSV';
period: { start: string; end: string };
}) {
const response = await this.client.post('/reports/generate', data);
return response.data;
}
async getReports(params?: { page?: number; limit?: number; websiteId?: string; type?: string }) {
const response = await this.client.get('/reports', { params });
return response.data;
}
async getReport(id: string) {
const response = await this.client.get(`/reports/${id}`);
return response.data;
}
async downloadReport(id: string) {
const response = await this.client.get(`/reports/${id}/download`);
return response.data;
}
async getComplianceTrends(websiteId: string, days: number = 30) {
const response = await this.client.get(`/reports/trends/${websiteId}`, { params: { days } });
return response.data;
}
// Health endpoints
async getHealth() {
const response = await this.client.get('/health');
return response.data;
}
async getStats() {
const response = await this.client.get('/health/stats');
return response.data;
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/globals.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,112 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginPage() {
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.login(data.email, data.password);
setAuth(response.user, response.token);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
{...register('email')}
type="email"
autoComplete="email"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Email address"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
{...register('password')}
type="password"
autoComplete="current-password"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
</div>
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-400">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-primary hover:text-primary/80"
>
Sign up
</Link>
</span>
</div>
</form>
);
}

View file

@ -0,0 +1,141 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().optional(),
company: z.string().optional(),
});
type RegisterForm = z.infer<typeof registerSchema>;
export function RegisterPage() {
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterForm) => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.register(data);
setAuth(response.user, response.token);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email address
</label>
<input
{...register('email')}
type="email"
autoComplete="email"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Email address"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Password
</label>
<input
{...register('password')}
type="password"
autoComplete="new-password"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Name (optional)
</label>
<input
{...register('name')}
type="text"
autoComplete="name"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Company (optional)
</label>
<input
{...register('company')}
type="text"
autoComplete="organization"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Your company"
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
</div>
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-400">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-primary hover:text-primary/80"
>
Sign in
</Link>
</span>
</div>
</form>
);
}

View file

@ -0,0 +1,243 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Globe, Scan, AlertCircle, CheckCircle2, TrendingUp, TrendingDown } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { format } from 'date-fns';
export function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['stats'],
queryFn: () => apiClient.getStats(),
});
const { data: recentScans } = useQuery({
queryKey: ['recent-scans'],
queryFn: () => apiClient.getScans({ limit: 5 }),
});
const { data: websites } = useQuery({
queryKey: ['websites-summary'],
queryFn: () => apiClient.getWebsites({ limit: 5 }),
});
if (statsLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Websites</CardTitle>
<Globe className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.database?.websites || 0}</div>
<p className="text-xs text-muted-foreground">
Active websites being monitored
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Scans</CardTitle>
<Scan className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.database?.scanResults || 0}</div>
<p className="text-xs text-muted-foreground">
Accessibility scans performed
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Queue Status</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.queue?.active || 0}</div>
<p className="text-xs text-muted-foreground">
Active scans in progress
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.queue?.completed && stats?.queue?.failed
? Math.round((stats.queue.completed / (stats.queue.completed + stats.queue.failed)) * 100)
: 100}%
</div>
<p className="text-xs text-muted-foreground">
Scan completion rate
</p>
</CardContent>
</Card>
</div>
{/* Charts Row */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Compliance Trends</CardTitle>
<CardDescription>Average compliance scores over time</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={[
{ date: 'Mon', score: 85 },
{ date: 'Tue', score: 87 },
{ date: 'Wed', score: 86 },
{ date: 'Thu', score: 89 },
{ date: 'Fri', score: 91 },
{ date: 'Sat', score: 90 },
{ date: 'Sun', score: 92 },
]}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="score"
stroke="hsl(var(--primary))"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Issue Distribution</CardTitle>
<CardDescription>Violations by severity level</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{ severity: 'Critical', count: 5, fill: 'hsl(var(--destructive))' },
{ severity: 'Serious', count: 12, fill: 'hsl(var(--warning))' },
{ severity: 'Moderate', count: 25, fill: 'hsl(var(--primary))' },
{ severity: 'Minor', count: 45, fill: 'hsl(var(--muted))' },
]}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="severity" />
<YAxis />
<Tooltip />
<Bar dataKey="count" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Recent Activity */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Scans</CardTitle>
<CardDescription>Latest accessibility scan results</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/scans">View all</Link>
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentScans?.scans?.map((scan: any) => (
<div key={scan.id} className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{scan.website?.name}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(scan.createdAt), 'MMM d, h:mm a')}
</p>
</div>
<div className="flex items-center gap-2">
{scan.status === 'COMPLETED' ? (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">
{scan.result?.summary?.score || 0}%
</span>
{scan.result?.summary?.score > 90 ? (
<TrendingUp className="h-4 w-4 text-success" />
) : (
<TrendingDown className="h-4 w-4 text-destructive" />
)}
</div>
) : (
<span className="text-xs text-muted-foreground">
{scan.status}
</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Websites</CardTitle>
<CardDescription>Your monitored websites</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/websites">Manage</Link>
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{websites?.websites?.map((website: any) => (
<div key={website.id} className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{website.name}</p>
<p className="text-xs text-muted-foreground">{website.url}</p>
</div>
<div className="flex items-center gap-2">
{website.complianceScore !== null ? (
<span className="text-sm font-medium">
{Math.round(website.complianceScore)}%
</span>
) : (
<span className="text-xs text-muted-foreground">
No scans yet
</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function ReportsPage() {
return (
<div>
<h1 className="text-2xl font-bold">Reports</h1>
<p className="text-muted-foreground">Compliance reports and analytics coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function ScanDetailPage() {
return (
<div>
<h1 className="text-2xl font-bold">Scan Results</h1>
<p className="text-muted-foreground">Scan detail page coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function ScansPage() {
return (
<div>
<h1 className="text-2xl font-bold">Scans</h1>
<p className="text-muted-foreground">Scan history and management coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function SettingsPage() {
return (
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-muted-foreground">Account settings and preferences coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function WebsiteDetailPage() {
return (
<div>
<h1 className="text-2xl font-bold">Website Details</h1>
<p className="text-muted-foreground">Website detail page coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,88 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Plus, Globe, ExternalLink, MoreVertical } from 'lucide-react';
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
export function WebsitesPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['websites', page],
queryFn: () => apiClient.getWebsites({ page, limit: 12 }),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Websites</h1>
<p className="text-muted-foreground">
Manage your monitored websites and their scan schedules
</p>
</div>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Website
</Button>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i} className="h-48 animate-pulse bg-muted" />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.websites?.map((website: any) => (
<Card key={website.id} className="p-6">
<div className="flex items-start justify-between">
<Globe className="h-5 w-5 text-muted-foreground" />
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
<div className="mt-4">
<h3 className="font-semibold">{website.name}</h3>
<a
href={website.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1 mt-1"
>
{website.url}
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="mt-4 flex items-center justify-between">
<div>
{website.complianceScore !== null ? (
<div>
<span className="text-2xl font-bold">
{Math.round(website.complianceScore)}%
</span>
<p className="text-xs text-muted-foreground">Compliance</p>
</div>
) : (
<p className="text-sm text-muted-foreground">No scans yet</p>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/websites/${website.id}`}>View Details</Link>
</Button>
</div>
{website.lastScanAt && (
<p className="text-xs text-muted-foreground mt-2">
Last scan: {format(new Date(website.lastScanAt), 'MMM d, h:mm a')}
</p>
)}
</Card>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name?: string;
company?: string;
role: string;
apiKey?: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
setAuth: (user: User, token: string) => void;
setUser: (user: User) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
setAuth: (user, token) =>
set({
user,
token,
isAuthenticated: true,
isLoading: false,
}),
setUser: (user) => set({ user }),
setLoading: (loading) => set({ isLoading: loading }),
logout: () =>
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
}),
}),
{
name: 'wcag-auth',
partialize: (state) => ({
token: state.token,
}),
}
)
);

View file

@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--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%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--warning: 47.9 95.8% 53.1%;
--warning-foreground: 26 83.3% 14.1%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 144.9 80.4% 10%;
--warning: 47.9 80% 60%;
--warning-foreground: 47.5 90% 10%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,85 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
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))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [
{ "path": "../shared" },
{ "path": "../config" }
]
}

View file

@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});

View file

@ -0,0 +1,47 @@
version: '3.8'
services:
# PostgreSQL Database (for local development)
postgres:
image: postgres:16-alpine
container_name: wcag-ada-postgres-dev
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: wcag_ada_dev
volumes:
- postgres_dev_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d wcag_ada_dev"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wcag-ada-dev-network
# Redis (for local development)
redis:
image: redis:7-alpine
container_name: wcag-ada-redis-dev
command: redis-server --appendonly yes
volumes:
- redis_dev_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wcag-ada-dev-network
networks:
wcag-ada-dev-network:
driver: bridge
volumes:
postgres_dev_data:
redis_dev_data:

View file

@ -0,0 +1,28 @@
version: '3.8'
services:
# Test databases for CI/CD
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: wcag_ada_test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test_user -d wcag_ada_test"]
interval: 5s
timeout: 5s
retries: 5
redis-test:
image: redis:7-alpine
command: redis-server --appendonly yes
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5

View file

@ -0,0 +1,153 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: wcag-ada-postgres
environment:
POSTGRES_USER: wcag_user
POSTGRES_PASSWORD: wcag_password
POSTGRES_DB: wcag_ada
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wcag_user -d wcag_ada"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wcag-ada-network
# Redis
redis:
image: redis:7-alpine
container_name: wcag-ada-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wcag-ada-network
# API Service
api:
build:
context: ../..
dockerfile: apps/wcag-ada/api/Dockerfile
container_name: wcag-ada-api
environment:
NODE_ENV: production
DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada
REDIS_HOST: redis
REDIS_PORT: 6379
API_PORT: 3001
API_JWT_SECRET: ${API_JWT_SECRET:-change-this-in-production}
API_CORS_ORIGIN: ${API_CORS_ORIGIN:-http://localhost:8080}
ports:
- "3001:3001"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- wcag-ada-network
restart: unless-stopped
# Worker Service
worker:
build:
context: ../..
dockerfile: apps/wcag-ada/worker/Dockerfile
container_name: wcag-ada-worker
environment:
NODE_ENV: production
DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada
REDIS_HOST: redis
REDIS_PORT: 6379
WORKER_PORT: 3002
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
SCANNER_HEADLESS: "true"
ports:
- "3002:3002"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- wcag-ada-network
restart: unless-stopped
# Additional security options for Chromium
security_opt:
- seccomp:unconfined
cap_add:
- SYS_ADMIN
# Dashboard
dashboard:
build:
context: ../..
dockerfile: apps/wcag-ada/dashboard/Dockerfile
container_name: wcag-ada-dashboard
ports:
- "8080:8080"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- wcag-ada-network
restart: unless-stopped
# Database migrations (one-time job)
migrate:
build:
context: ../..
dockerfile: apps/wcag-ada/api/Dockerfile
container_name: wcag-ada-migrate
command: ["bunx", "prisma", "migrate", "deploy"]
environment:
DATABASE_URL: postgresql://wcag_user:wcag_password@postgres:5432/wcag_ada
depends_on:
postgres:
condition: service_healthy
networks:
- wcag-ada-network
restart: "no"
networks:
wcag-ada-network:
driver: bridge
volumes:
postgres_data:
redis_data:

View file

@ -0,0 +1,69 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: wcag-ada-api
namespace: wcag-ada
spec:
replicas: 3
selector:
matchLabels:
app: wcag-ada-api
template:
metadata:
labels:
app: wcag-ada-api
spec:
initContainers:
- name: wait-for-postgres
image: busybox:1.36
command: ['sh', '-c', 'until nc -z postgres-service 5432; do echo waiting for postgres; sleep 2; done']
- name: run-migrations
image: wcag-ada/api:latest
command: ["bunx", "prisma", "migrate", "deploy"]
envFrom:
- configMapRef:
name: wcag-ada-config
- secretRef:
name: wcag-ada-secrets
containers:
- name: api
image: wcag-ada/api:latest
ports:
- containerPort: 3001
envFrom:
- configMapRef:
name: wcag-ada-config
- secretRef:
name: wcag-ada-secrets
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: wcag-ada-api-service
namespace: wcag-ada
spec:
selector:
app: wcag-ada-api
ports:
- port: 3001
targetPort: 3001
type: ClusterIP

View file

@ -0,0 +1,47 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: wcag-ada-config
namespace: wcag-ada
data:
NODE_ENV: "production"
APP_NAME: "wcag-ada"
# API Configuration
API_PORT: "3001"
API_CORS_ORIGIN: "https://wcag-ada.example.com"
API_JWT_EXPIRES_IN: "7d"
API_RATE_LIMIT: "100"
API_RATE_WINDOW: "60000"
# Worker Configuration
WORKER_PORT: "3002"
WORKER_CONCURRENCY: "5"
WORKER_QUEUE_NAME: "accessibility-scans"
# Redis Configuration
REDIS_HOST: "redis-service"
REDIS_PORT: "6379"
REDIS_DB: "0"
# Scanner Configuration
SCANNER_HEADLESS: "true"
SCANNER_TIMEOUT: "30000"
SCANNER_VIEWPORT_WIDTH: "1920"
SCANNER_VIEWPORT_HEIGHT: "1080"
SCANNER_BLOCK_RESOURCES: "true"
SCANNER_BLOCK_PATTERNS: "font,image"
# Features
FEATURES_SCREENSHOTS: "true"
FEATURES_FIX_SUGGESTIONS: "true"
FEATURES_CUSTOM_RULES: "true"
FEATURES_SCHEDULED_SCANS: "true"
FEATURES_BULK_SCANNING: "false"
FEATURES_API_ACCESS: "true"
# Scheduler
SCHEDULER_TIMEZONE: "America/New_York"
SCHEDULER_MAX_CONCURRENT_SCANS: "3"
SCHEDULER_RETRY_ATTEMPTS: "3"
SCHEDULER_RETRY_DELAY: "5000"

View file

@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: wcag-ada-dashboard
namespace: wcag-ada
spec:
replicas: 2
selector:
matchLabels:
app: wcag-ada-dashboard
template:
metadata:
labels:
app: wcag-ada-dashboard
spec:
containers:
- name: dashboard
image: wcag-ada/dashboard:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
name: wcag-ada-dashboard-service
namespace: wcag-ada
spec:
selector:
app: wcag-ada-dashboard
ports:
- port: 80
targetPort: 8080
type: ClusterIP

View file

@ -0,0 +1,38 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wcag-ada-ingress
namespace: wcag-ada
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
tls:
- hosts:
- wcag-ada.example.com
- api.wcag-ada.example.com
secretName: wcag-ada-tls
rules:
- host: wcag-ada.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wcag-ada-dashboard-service
port:
number: 80
- host: api.wcag-ada.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wcag-ada-api-service
port:
number: 3001

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: wcag-ada
labels:
name: wcag-ada

View file

@ -0,0 +1,84 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: wcag-ada
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: wcag-ada
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
env:
- name: POSTGRES_USER
value: wcag_user
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: wcag-ada-secrets
key: DATABASE_URL
- name: POSTGRES_DB
value: wcag_ada
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command:
- pg_isready
- -U
- wcag_user
- -d
- wcag_ada
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- wcag_user
- -d
- wcag_ada
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: postgres-service
namespace: wcag-ada
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
type: ClusterIP

View file

@ -0,0 +1,77 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
namespace: wcag-ada
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: wcag-ada
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
command:
- redis-server
- --appendonly
- "yes"
ports:
- containerPort: 6379
volumeMounts:
- name: redis-storage
mountPath: /data
livenessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "200m"
volumes:
- name: redis-storage
persistentVolumeClaim:
claimName: redis-pvc
---
apiVersion: v1
kind: Service
metadata:
name: redis-service
namespace: wcag-ada
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
type: ClusterIP

View file

@ -0,0 +1,26 @@
apiVersion: v1
kind: Secret
metadata:
name: wcag-ada-secrets
namespace: wcag-ada
type: Opaque
stringData:
# Database
DATABASE_URL: "postgresql://wcag_user:wcag_password@postgres-service:5432/wcag_ada"
# Redis
REDIS_PASSWORD: ""
# API
API_JWT_SECRET: "your-super-secret-jwt-key-change-in-production"
# Email (optional)
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "noreply@wcag-ada.com"
# Slack (optional)
SLACK_WEBHOOK_URL: ""
SLACK_CHANNEL: "#wcag-alerts"

View file

@ -0,0 +1,67 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: wcag-ada-worker
namespace: wcag-ada
spec:
replicas: 2
selector:
matchLabels:
app: wcag-ada-worker
template:
metadata:
labels:
app: wcag-ada-worker
spec:
initContainers:
- name: wait-for-services
image: busybox:1.36
command: ['sh', '-c', 'until nc -z postgres-service 5432 && nc -z redis-service 6379 && nc -z wcag-ada-api-service 3001; do echo waiting for services; sleep 2; done']
containers:
- name: worker
image: wcag-ada/worker:latest
ports:
- containerPort: 3002
envFrom:
- configMapRef:
name: wcag-ada-config
- secretRef:
name: wcag-ada-secrets
livenessProbe:
httpGet:
path: /health
port: 3002
initialDelaySeconds: 40
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3002
initialDelaySeconds: 10
periodSeconds: 5
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
securityContext:
capabilities:
add:
- SYS_ADMIN
runAsUser: 1001
runAsGroup: 1001
---
apiVersion: v1
kind: Service
metadata:
name: wcag-ada-worker-service
namespace: wcag-ada
spec:
selector:
app: wcag-ada-worker
ports:
- port: 3002
targetPort: 3002
type: ClusterIP

View file

@ -0,0 +1,32 @@
{
"name": "@wcag-ada/root",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"clean": "turbo run clean",
"dev:api": "cd api && bun run dev",
"dev:worker": "cd worker && bun run dev",
"dev:dashboard": "cd dashboard && bun run dev",
"dev:scanner": "cd scanner && bun run example",
"migrate": "cd api && bunx prisma migrate dev",
"db:push": "cd api && bunx prisma db push",
"db:studio": "cd api && bunx prisma studio",
"db:seed": "cd api && bun run seed"
},
"devDependencies": {
"turbo": "latest"
},
"workspaces": [
"api",
"config",
"dashboard",
"scanner",
"shared",
"worker"
]
}

View file

@ -0,0 +1,162 @@
# WCAG-ADA Accessibility Scanner
A high-performance accessibility scanner built with Playwright and axe-core for WCAG compliance testing.
## Features
- 🚀 Fast scanning with Playwright
- ♿ WCAG 2.0, 2.1, and 2.2 compliance testing
- 📊 Detailed violation reports with fix suggestions
- 📸 Screenshot capture for violations
- 🔐 Authentication support (basic, form-based, custom)
- 📱 Mobile and desktop viewport testing
- 🎯 Custom rule support
- 🚫 Resource blocking for faster scans
## Installation
```bash
bun install
```
## Usage
### Basic Example
```typescript
import { AccessibilityScanner } from '@wcag-ada/scanner';
const scanner = new AccessibilityScanner();
await scanner.initialize();
const result = await scanner.scan({
url: 'https://example.com',
wcagLevel: {
level: 'AA',
version: '2.1'
}
});
console.log(`Compliance Score: ${result.summary.score}%`);
console.log(`Violations Found: ${result.summary.violationCount}`);
await scanner.close();
```
### Advanced Options
```typescript
const result = await scanner.scan({
url: 'https://example.com',
wcagLevel: {
level: 'AA',
version: '2.1'
},
includeScreenshots: true,
viewport: {
width: 1920,
height: 1080,
isMobile: false
},
authenticate: {
type: 'form',
loginUrl: 'https://example.com/login',
credentials: {
username: 'user@example.com',
password: 'password'
},
selectors: {
username: '#email',
password: '#password',
submit: 'button[type="submit"]'
}
},
excludeSelectors: [
'.cookie-banner',
'[aria-hidden="true"]'
],
waitForSelector: '#main-content',
timeout: 60000
});
```
### Scan Multiple Pages
```typescript
const urls = [
'https://example.com',
'https://example.com/about',
'https://example.com/contact'
];
const results = await scanner.scanMultiplePages(urls, {
wcagLevel: { level: 'AA', version: '2.1' }
});
```
## Scan Results
The scanner returns a comprehensive `AccessibilityScanResult` object containing:
- **Summary**: Overall compliance score, violation counts by severity
- **Violations**: Detailed list of accessibility issues with:
- Impact level (critical, serious, moderate, minor)
- Affected elements with HTML snippets
- Fix suggestions
- WCAG criteria mapping
- Screenshots (if enabled)
- **Passes**: Rules that passed
- **Incomplete**: Rules that need manual review
- **Page Metadata**: Title, language, element counts, landmark presence
- **WCAG Compliance**: Level-specific compliance status
## Performance Optimization
The scanner is optimized for speed:
- Blocks unnecessary resources (images, fonts, analytics)
- Parallel page scanning support
- Configurable timeouts
- Headless browser mode
## Custom Rules
Add custom accessibility rules:
```typescript
const customRules = [{
id: 'custom-button-size',
selector: 'button',
tags: ['custom', 'wcag21aa'],
description: 'Buttons must have minimum touch target size',
help: 'Ensure buttons are at least 44x44 pixels',
severity: 'serious',
validator: (element) => {
const rect = element.getBoundingClientRect();
return rect.width >= 44 && rect.height >= 44;
}
}];
const result = await scanner.scan({
url: 'https://example.com',
customRules
});
```
## Running the Example
```bash
bun run src/example.ts
```
## Development
```bash
# Build
bun run build
# Watch mode
bun run dev
# Type checking
bun run typecheck
```

View file

@ -0,0 +1,29 @@
{
"name": "@wcag-ada/scanner",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b",
"dev": "tsc -w",
"test": "playwright test",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@axe-core/playwright": "^4.8.3",
"@playwright/test": "^1.40.1",
"axe-core": "^4.8.3",
"playwright": "^1.40.1",
"pdfkit": "^0.14.0",
"handlebars": "^4.7.8",
"p-queue": "^7.4.1",
"@wcag-ada/shared": "workspace:*",
"@wcag-ada/config": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/pdfkit": "^0.13.4",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,285 @@
import { chromium, Browser, BrowserContext, Page } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import type { AxeResults } from 'axe-core';
import type {
AccessibilityScanOptions,
ViewportSize,
AuthenticationConfig
} from '@wcag-ada/shared';
import { getWCAGTags, RESOURCE_BLOCK_PATTERNS } from '@wcag-ada/shared';
import { getScannerConfig, type ScannerConfig } from '@wcag-ada/config';
export interface AccessibilityBrowserOptions {
headless?: boolean;
scanTimeout?: number;
defaultViewport?: ViewportSize;
blockResources?: boolean;
}
export class AccessibilityBrowser {
private browser?: Browser;
private context?: BrowserContext;
private logger: any;
private config: ScannerConfig;
private options: AccessibilityBrowserOptions;
private initialized = false;
constructor(logger?: any, options?: AccessibilityBrowserOptions) {
this.logger = logger || console;
this.config = getScannerConfig();
// Merge config with provided options
this.options = {
headless: options?.headless ?? this.config.headless,
scanTimeout: options?.scanTimeout ?? this.config.timeout,
defaultViewport: options?.defaultViewport ?? this.config.viewport,
blockResources: options?.blockResources ?? this.config.blockResources,
};
}
async initialize(): Promise<void> {
if (this.initialized) return;
this.browser = await chromium.launch({
headless: this.options.headless,
args: this.config.browsers.chromium.args,
});
this.context = await this.browser.newContext({
viewport: this.options.defaultViewport,
});
// Block resources for faster scanning if enabled
if (this.options.blockResources) {
await this.context.route('**/*', (route) => {
const url = route.request().url();
const shouldBlock = RESOURCE_BLOCK_PATTERNS.some(pattern => {
if (pattern.includes('*')) {
const regex = new RegExp(pattern.replace('*', '.*'));
return regex.test(url);
}
return url.includes(pattern);
});
if (shouldBlock) {
route.abort();
} else {
route.continue();
}
});
}
this.initialized = true;
}
async close(): Promise<void> {
if (this.context) await this.context.close();
if (this.browser) await this.browser.close();
this.initialized = false;
}
async scanPage(options: AccessibilityScanOptions): Promise<{
page: Page;
axeResults: AxeResults;
html: string;
}> {
if (!this.context) throw new Error('Browser not initialized');
const { url, viewport, authenticate, wcagLevel, excludeSelectors, waitForSelector } = options;
const page = await this.context.newPage();
try {
// Set viewport if different from default
if (viewport) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
}
// Handle authentication if needed
if (authenticate) {
await this.handleAuthentication(page, authenticate);
}
// Navigate to URL
await page.goto(url, {
waitUntil: 'networkidle',
timeout: options.timeout || this.config.pageLoadTimeout,
});
// Wait for specific selector if provided
if (waitForSelector) {
await page.waitForSelector(waitForSelector, {
timeout: 10000,
});
}
// Additional wait for dynamic content
await page.waitForTimeout(2000);
// Get page HTML
const html = await page.content();
// Configure axe
const axeBuilder = new AxeBuilder({ page });
// Set WCAG tags
if (wcagLevel) {
const tags = getWCAGTags(wcagLevel);
axeBuilder.withTags(tags);
}
// Exclude selectors if provided
if (excludeSelectors && excludeSelectors.length > 0) {
excludeSelectors.forEach(selector => {
axeBuilder.exclude(selector);
});
}
// Set options
axeBuilder.options({
resultTypes: this.config.axe.resultTypes,
elementRef: true,
runOnly: {
type: 'tag',
values: wcagLevel ? getWCAGTags(wcagLevel) : this.config.axe.tags,
},
});
// Run accessibility scan
const axeResults = await Promise.race([
axeBuilder.analyze(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Scan timeout')), this.options.scanTimeout!)
),
]);
return {
page,
axeResults,
html,
};
} catch (error) {
await page.close();
throw error;
}
}
private async handleAuthentication(page: Page, auth: AuthenticationConfig): Promise<void> {
switch (auth.type) {
case 'basic':
if (auth.credentials?.username && auth.credentials?.password) {
await page.context().setHTTPCredentials({
username: auth.credentials.username,
password: auth.credentials.password,
});
}
break;
case 'form':
if (auth.loginUrl) {
await page.goto(auth.loginUrl);
if (auth.selectors?.username && auth.credentials?.username) {
await page.fill(auth.selectors.username, auth.credentials.username);
}
if (auth.selectors?.password && auth.credentials?.password) {
await page.fill(auth.selectors.password, auth.credentials.password);
}
if (auth.selectors?.submit) {
await page.click(auth.selectors.submit);
await page.waitForNavigation({ waitUntil: 'networkidle' });
}
}
break;
case 'custom':
if (auth.customScript) {
await page.evaluate(auth.customScript);
}
break;
}
}
async captureViolationScreenshots(
page: Page,
violations: AxeResults['violations']
): Promise<Map<string, string>> {
const screenshots = new Map<string, string>();
for (const violation of violations) {
for (let i = 0; i < violation.nodes.length; i++) {
const node = violation.nodes[i];
if (node.target && node.target.length > 0) {
try {
const selector = node.target[0];
const element = await page.$(selector);
if (element) {
const screenshot = await element.screenshot({
type: 'png',
});
const key = `${violation.id}_${i}`;
screenshots.set(key, screenshot.toString('base64'));
}
} catch (error) {
this.logger?.warn(`Failed to capture screenshot for ${violation.id}:`, error);
}
}
}
}
return screenshots;
}
async evaluateCustomRules(page: Page, customRules: any[]): Promise<any[]> {
const results = [];
for (const rule of customRules) {
try {
const elements = await page.$$(rule.selector);
const violations = [];
for (const element of elements) {
const passes = await page.evaluate(
(el, validatorStr) => {
const validator = new Function('element', `return (${validatorStr})(element)`);
return validator(el);
},
element,
rule.validator.toString()
);
if (!passes) {
const html = await element.evaluate(el => el.outerHTML);
violations.push({
html,
target: [rule.selector],
});
}
}
if (violations.length > 0) {
results.push({
id: rule.id,
description: rule.description,
help: rule.help,
helpUrl: rule.helpUrl,
impact: rule.severity,
tags: rule.tags,
nodes: violations,
});
}
} catch (error) {
this.logger?.error(`Failed to evaluate custom rule ${rule.id}:`, error);
}
}
return results;
}
}

View file

@ -0,0 +1,303 @@
import { AccessibilityBrowser } from './accessibility-browser';
import type {
AccessibilityScanOptions,
AccessibilityScanResult,
AccessibilityViolation,
ScanSummary,
PageMetadata,
WCAGCompliance,
WCAGLevel,
CriteriaResult
} from '@wcag-ada/shared';
import {
calculateComplianceScore,
summarizeViolations,
generateFixSuggestion,
extractPageMetadata,
isCompliantWithLevel,
CRITICAL_WCAG_CRITERIA
} from '@wcag-ada/shared';
import type { AxeResults, Result as AxeResult } from 'axe-core';
import { initializeWcagConfig } from '@wcag-ada/config';
export class AccessibilityScanner {
private browser: AccessibilityBrowser;
private logger: any;
constructor(logger?: any) {
this.logger = logger || console;
// Initialize config for scanner service
initializeWcagConfig();
this.browser = new AccessibilityBrowser(logger);
}
async initialize(): Promise<void> {
await this.browser.initialize();
}
async close(): Promise<void> {
await this.browser.close();
}
async scan(options: AccessibilityScanOptions): Promise<AccessibilityScanResult> {
const startTime = Date.now();
try {
// Perform the scan
const { page, axeResults, html } = await this.browser.scanPage(options);
// Capture screenshots if requested
let screenshotMap: Map<string, string> | undefined;
if (options.includeScreenshots) {
screenshotMap = await this.browser.captureViolationScreenshots(page, axeResults.violations);
}
// Evaluate custom rules if provided
let customViolations: AxeResult[] = [];
if (options.customRules && options.customRules.length > 0) {
customViolations = await this.browser.evaluateCustomRules(page, options.customRules);
}
// Close the page
await page.close();
// Process results
const violations = this.processViolations(
[...axeResults.violations, ...customViolations],
screenshotMap
);
// Extract metadata
const pageMetadata = this.extractPageMetadata(html, options);
// Calculate summary
const summary = this.calculateSummary(axeResults, violations);
// Determine WCAG compliance
const wcagCompliance = this.calculateWCAGCompliance(
violations,
axeResults.passes,
options.wcagLevel || { level: 'AA', version: '2.1' },
summary.score
);
const scanDuration = Date.now() - startTime;
return {
url: options.url,
timestamp: new Date(),
scanDuration,
summary,
violations,
passes: axeResults.passes,
incomplete: axeResults.incomplete,
inapplicable: axeResults.inapplicable,
pageMetadata,
wcagCompliance,
};
} catch (error) {
this.logger.error('Scan failed:', error);
throw error;
}
}
private processViolations(
axeViolations: AxeResult[],
screenshots?: Map<string, string>
): AccessibilityViolation[] {
return axeViolations.map(violation => {
const nodes = violation.nodes.map((node, index) => {
const screenshotKey = `${violation.id}_${index}`;
return {
html: node.html,
target: node.target as string[],
xpath: node.xpath?.[0],
failureSummary: node.failureSummary,
screenshot: screenshots?.get(screenshotKey),
relatedNodes: node.relatedNodes?.map(related => ({
html: related.html,
target: related.target as string[],
})),
};
});
return {
id: violation.id,
impact: violation.impact!,
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
tags: violation.tags,
nodes,
wcagCriteria: this.mapToWCAGCriteria(violation.tags),
fixSuggestion: generateFixSuggestion(violation.id),
};
});
}
private extractPageMetadata(html: string, options: AccessibilityScanOptions): PageMetadata {
const metadata = extractPageMetadata(html);
return {
title: metadata.title || 'Untitled Page',
url: options.url,
viewport: options.viewport || { width: 1280, height: 720 },
language: metadata.language,
doctype: this.extractDoctype(html),
hasLandmarks: metadata.hasLandmarks || false,
hasHeadings: metadata.hasHeadings || false,
imagesCount: metadata.imagesCount || 0,
formsCount: metadata.formsCount || 0,
linksCount: metadata.linksCount || 0,
};
}
private extractDoctype(html: string): string {
const doctypeMatch = html.match(/<!DOCTYPE\s+([^>]+)>/i);
return doctypeMatch ? doctypeMatch[1].trim() : 'unknown';
}
private calculateSummary(
axeResults: AxeResults,
violations: AccessibilityViolation[]
): ScanSummary {
const violationSummary = summarizeViolations(violations);
const violationCount = violations.reduce((sum, v) => sum + v.nodes.length, 0);
const result: AccessibilityScanResult = {
violations,
passes: axeResults.passes,
summary: {} as ScanSummary,
} as AccessibilityScanResult;
const score = calculateComplianceScore(result);
return {
violationCount,
passCount: axeResults.passes.length,
incompleteCount: axeResults.incomplete.length,
inapplicableCount: axeResults.inapplicable.length,
...violationSummary,
score,
} as ScanSummary;
}
private calculateWCAGCompliance(
violations: AccessibilityViolation[],
passes: AxeResult[],
wcagLevel: WCAGLevel,
score: number
): WCAGCompliance {
const criteriaMap = new Map<string, CriteriaResult>();
// Initialize critical criteria
CRITICAL_WCAG_CRITERIA.forEach(criterion => {
criteriaMap.set(criterion, {
criterion,
level: this.getCriterionLevel(criterion),
passed: true,
violations: [],
});
});
// Process violations
violations.forEach(violation => {
violation.wcagCriteria?.forEach(criterion => {
if (!criteriaMap.has(criterion)) {
criteriaMap.set(criterion, {
criterion,
level: this.getCriterionLevel(criterion),
passed: false,
violations: [violation.id],
});
} else {
const result = criteriaMap.get(criterion)!;
result.passed = false;
result.violations.push(violation.id);
}
});
});
const criteriaResults = Array.from(criteriaMap.values());
const isCompliant = isCompliantWithLevel(score, wcagLevel.level);
return {
level: wcagLevel,
isCompliant,
criteriaResults,
overallScore: score,
};
}
private mapToWCAGCriteria(tags: string[]): string[] {
const criteriaMap: Record<string, string[]> = {
'wcag111': ['1.1.1'],
'wcag131': ['1.3.1'],
'wcag141': ['1.4.1'],
'wcag143': ['1.4.3'],
'wcag211': ['2.1.1'],
'wcag212': ['2.1.2'],
'wcag241': ['2.4.1'],
'wcag242': ['2.4.2'],
'wcag243': ['2.4.3'],
'wcag244': ['2.4.4'],
'wcag311': ['3.1.1'],
'wcag331': ['3.3.1'],
'wcag332': ['3.3.2'],
'wcag411': ['4.1.1'],
'wcag412': ['4.1.2'],
};
const criteria = new Set<string>();
tags.forEach(tag => {
const mapped = criteriaMap[tag];
if (mapped) {
mapped.forEach(c => criteria.add(c));
}
});
return Array.from(criteria);
}
private getCriterionLevel(criterion: string): 'A' | 'AA' | 'AAA' {
const levelMap: Record<string, 'A' | 'AA' | 'AAA'> = {
'1.1.1': 'A',
'1.3.1': 'A',
'1.4.1': 'A',
'1.4.3': 'AA',
'2.1.1': 'A',
'2.1.2': 'A',
'2.4.1': 'A',
'2.4.2': 'A',
'2.4.3': 'A',
'2.4.4': 'A',
'3.1.1': 'A',
'3.3.1': 'A',
'3.3.2': 'A',
'4.1.1': 'A',
'4.1.2': 'A',
};
return levelMap[criterion] || 'A';
}
async scanMultiplePages(
urls: string[],
baseOptions: Omit<AccessibilityScanOptions, 'url'>
): Promise<AccessibilityScanResult[]> {
const results: AccessibilityScanResult[] = [];
for (const url of urls) {
try {
const result = await this.scan({ ...baseOptions, url });
results.push(result);
} catch (error) {
this.logger.error(`Failed to scan ${url}:`, error);
}
}
return results;
}
}

View file

@ -0,0 +1,79 @@
import { AccessibilityScanner } from './core/scanner';
import type { AccessibilityScanOptions } from '@wcag-ada/shared';
import { createLogger } from '@lib/service/core-logger';
const logger = createLogger('scanner-example');
async function runExample() {
const scanner = new AccessibilityScanner();
try {
// Initialize the browser
await scanner.initialize();
// Define scan options
const scanOptions: AccessibilityScanOptions = {
url: 'https://example.com',
wcagLevel: {
level: 'AA',
version: '2.1'
},
includeScreenshots: true,
viewport: {
width: 1920,
height: 1080
},
excludeSelectors: [
'.cookie-banner',
'[aria-hidden="true"]'
]
};
logger.info('Starting accessibility scan...');
const result = await scanner.scan(scanOptions);
// Display summary
logger.info('\n=== SCAN SUMMARY ===');
logger.info(`URL: ${result.url}`);
logger.info(`Compliance Score: ${result.summary.score}%`);
logger.info(`WCAG ${result.wcagCompliance.level.version} Level ${result.wcagCompliance.level.level}: ${result.wcagCompliance.isCompliant ? 'PASSED' : 'FAILED'}`);
logger.info(`\nViolations: ${result.summary.violationCount}`);
logger.info(`- Critical: ${result.summary.criticalIssues}`);
logger.info(`- Serious: ${result.summary.seriousIssues}`);
logger.info(`- Moderate: ${result.summary.moderateIssues}`);
logger.info(`- Minor: ${result.summary.minorIssues}`);
// Display top violations
if (result.violations.length > 0) {
logger.info('\n=== TOP VIOLATIONS ===');
result.violations.slice(0, 5).forEach((violation, index) => {
logger.info(`\n${index + 1}. ${violation.help}`);
logger.info(` Impact: ${violation.impact}`);
logger.info(` Affected elements: ${violation.nodes.length}`);
logger.info(` Fix: ${violation.fixSuggestion}`);
logger.info(` More info: ${violation.helpUrl}`);
});
}
// Display page metadata
logger.info('\n=== PAGE METADATA ===');
logger.info(`Title: ${result.pageMetadata.title}`);
logger.info(`Language: ${result.pageMetadata.language || 'Not specified'}`);
logger.info(`Images: ${result.pageMetadata.imagesCount}`);
logger.info(`Forms: ${result.pageMetadata.formsCount}`);
logger.info(`Links: ${result.pageMetadata.linksCount}`);
logger.info(`Has landmarks: ${result.pageMetadata.hasLandmarks ? 'Yes' : 'No'}`);
logger.info(`Has headings: ${result.pageMetadata.hasHeadings ? 'Yes' : 'No'}`);
} catch (error) {
logger.error('Scan failed:', error);
} finally {
// Always close the browser
await scanner.close();
}
}
// Run the example
if (require.main === module) {
runExample().catch((error) => logger.error('Example failed:', error));
}

View file

@ -0,0 +1,14 @@
export { AccessibilityScanner } from './core/scanner';
export { AccessibilityBrowser } from './core/accessibility-browser';
export type { AccessibilityBrowserOptions } from './core/accessibility-browser';
// Re-export shared types for convenience
export type {
AccessibilityScanOptions,
AccessibilityScanResult,
AccessibilityViolation,
WCAGLevel,
Website,
ScanJob,
ComplianceReport,
} from '@wcag-ada/shared';

View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"],
"references": [
{ "path": "../shared" }
]
}

View file

@ -0,0 +1,41 @@
#!/bin/bash
set -e
# Script to build all Docker images locally
echo "Building WCAG-ADA Docker images..."
# Get the repository root (two levels up from scripts directory)
REPO_ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
WCAG_ROOT=$(cd "$(dirname "$0")/.." && pwd)
# Set default registry if not provided
REGISTRY=${DOCKER_REGISTRY:-"wcag-ada"}
# Build API image
echo "Building API image..."
docker build \
-f "$WCAG_ROOT/api/Dockerfile" \
-t "$REGISTRY/api:latest" \
"$REPO_ROOT"
# Build Worker image
echo "Building Worker image..."
docker build \
-f "$WCAG_ROOT/worker/Dockerfile" \
-t "$REGISTRY/worker:latest" \
"$REPO_ROOT"
# Build Dashboard image
echo "Building Dashboard image..."
docker build \
-f "$WCAG_ROOT/dashboard/Dockerfile" \
-t "$REGISTRY/dashboard:latest" \
"$REPO_ROOT"
echo "All images built successfully!"
echo ""
echo "Images created:"
echo " - $REGISTRY/api:latest"
echo " - $REGISTRY/worker:latest"
echo " - $REGISTRY/dashboard:latest"

View file

@ -0,0 +1,71 @@
#!/bin/bash
set -e
# Script to deploy to Kubernetes
NAMESPACE=${NAMESPACE:-"wcag-ada"}
ENVIRONMENT=${ENVIRONMENT:-"staging"}
echo "Deploying WCAG-ADA to Kubernetes..."
echo "Namespace: $NAMESPACE"
echo "Environment: $ENVIRONMENT"
# Get the script directory
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
K8S_DIR="$SCRIPT_DIR/../k8s"
# Create namespace if it doesn't exist
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
# Apply configurations
echo "Applying configurations..."
kubectl apply -f "$K8S_DIR/configmap.yaml" -n $NAMESPACE
# Apply secrets (only if file exists)
if [ -f "$K8S_DIR/secrets-$ENVIRONMENT.yaml" ]; then
echo "Applying environment-specific secrets..."
kubectl apply -f "$K8S_DIR/secrets-$ENVIRONMENT.yaml" -n $NAMESPACE
else
echo "Warning: No environment-specific secrets found. Using default secrets."
kubectl apply -f "$K8S_DIR/secrets.yaml" -n $NAMESPACE
fi
# Deploy services
echo "Deploying PostgreSQL..."
kubectl apply -f "$K8S_DIR/postgres.yaml" -n $NAMESPACE
echo "Deploying Redis..."
kubectl apply -f "$K8S_DIR/redis.yaml" -n $NAMESPACE
# Wait for databases to be ready
echo "Waiting for databases to be ready..."
kubectl wait --for=condition=ready pod -l app=postgres -n $NAMESPACE --timeout=300s
kubectl wait --for=condition=ready pod -l app=redis -n $NAMESPACE --timeout=300s
# Deploy applications
echo "Deploying API..."
kubectl apply -f "$K8S_DIR/api.yaml" -n $NAMESPACE
echo "Deploying Worker..."
kubectl apply -f "$K8S_DIR/worker.yaml" -n $NAMESPACE
echo "Deploying Dashboard..."
kubectl apply -f "$K8S_DIR/dashboard.yaml" -n $NAMESPACE
# Apply ingress
echo "Applying Ingress rules..."
kubectl apply -f "$K8S_DIR/ingress.yaml" -n $NAMESPACE
# Wait for deployments
echo "Waiting for deployments to be ready..."
kubectl rollout status deployment/wcag-ada-api -n $NAMESPACE
kubectl rollout status deployment/wcag-ada-worker -n $NAMESPACE
kubectl rollout status deployment/wcag-ada-dashboard -n $NAMESPACE
echo "Deployment complete!"
echo ""
echo "Services deployed:"
kubectl get services -n $NAMESPACE
echo ""
echo "Pods status:"
kubectl get pods -n $NAMESPACE

View file

@ -0,0 +1,52 @@
#!/bin/bash
set -e
# Script to set up local development environment
echo "Setting up WCAG-ADA local development environment..."
# Get the script directory
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
WCAG_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
# Check if .env exists
if [ ! -f "$WCAG_ROOT/.env" ]; then
echo "Creating .env file from template..."
cp "$WCAG_ROOT/.env.example" "$WCAG_ROOT/.env"
echo "Please edit .env file with your configuration values"
fi
# Start development databases
echo "Starting development databases..."
cd "$WCAG_ROOT"
docker-compose -f docker-compose.dev.yml up -d
# Wait for databases to be ready
echo "Waiting for databases..."
sleep 5
# Install dependencies
echo "Installing dependencies..."
bun install
# Run database migrations
echo "Running database migrations..."
cd "$WCAG_ROOT/api"
bunx prisma migrate dev
# Start services
echo ""
echo "Development environment ready!"
echo ""
echo "To start all services, run:"
echo " cd $WCAG_ROOT && bun run dev"
echo ""
echo "Or start individual services:"
echo " API: cd $WCAG_ROOT/api && bun run dev"
echo " Worker: cd $WCAG_ROOT/worker && bun run dev"
echo " Dashboard: cd $WCAG_ROOT/dashboard && bun run dev"
echo ""
echo "Database tools:"
echo " Prisma Studio: cd $WCAG_ROOT/api && bunx prisma studio"
echo " Redis CLI: docker exec -it wcag-ada-redis-dev redis-cli"
echo " PostgreSQL: docker exec -it wcag-ada-postgres-dev psql -U postgres -d wcag_ada_dev"

View file

@ -0,0 +1,18 @@
{
"name": "@wcag-ada/shared",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b",
"dev": "tsc -w",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"axe-core": "^4.8.3"
},
"devDependencies": {
"@types/node": "^20.11.5",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,81 @@
export const WCAG_TAGS = {
WCAG_2_0_A: ['wcag2a', 'wcag20a'],
WCAG_2_0_AA: ['wcag2aa', 'wcag20aa'],
WCAG_2_0_AAA: ['wcag2aaa', 'wcag20aaa'],
WCAG_2_1_A: ['wcag21a'],
WCAG_2_1_AA: ['wcag21aa'],
WCAG_2_1_AAA: ['wcag21aaa'],
WCAG_2_2_A: ['wcag22a'],
WCAG_2_2_AA: ['wcag22aa'],
WCAG_2_2_AAA: ['wcag22aaa'],
} as const;
export const IMPACT_SCORES = {
critical: 100,
serious: 75,
moderate: 50,
minor: 25,
} as const;
export const COMPLIANCE_THRESHOLDS = {
A: 0.95,
AA: 0.98,
AAA: 1.0,
} as const;
export const DEFAULT_VIEWPORT = {
width: 1280,
height: 720,
deviceScaleFactor: 1,
isMobile: false,
} as const;
export const MOBILE_VIEWPORT = {
width: 375,
height: 667,
deviceScaleFactor: 2,
isMobile: true,
} as const;
export const SCAN_TIMEOUTS = {
PAGE_LOAD: 30000,
SCAN_EXECUTION: 60000,
TOTAL: 120000,
} as const;
export const RESOURCE_BLOCK_PATTERNS = [
'*.gif',
'*.jpg',
'*.jpeg',
'*.png',
'*.svg',
'*.webp',
'*.woff',
'*.woff2',
'*.ttf',
'*.eot',
'fonts.googleapis.com',
'fonts.gstatic.com',
'googletagmanager.com',
'google-analytics.com',
'facebook.com',
'twitter.com',
'doubleclick.net',
];
export const CRITICAL_WCAG_CRITERIA = [
'1.1.1', // Non-text Content
'1.3.1', // Info and Relationships
'1.4.3', // Contrast (Minimum)
'2.1.1', // Keyboard
'2.1.2', // No Keyboard Trap
'2.4.1', // Bypass Blocks
'2.4.2', // Page Titled
'2.4.3', // Focus Order
'2.4.4', // Link Purpose
'3.1.1', // Language of Page
'3.3.1', // Error Identification
'3.3.2', // Labels or Instructions
'4.1.1', // Parsing
'4.1.2', // Name, Role, Value
] as const;

View file

@ -0,0 +1,3 @@
export * from './types';
export * from './constants';
export * from './utils';

View file

@ -0,0 +1,201 @@
import type { Result as AxeResult, ImpactValue, TagValue } from 'axe-core';
export interface WCAGLevel {
level: 'A' | 'AA' | 'AAA';
version: '2.0' | '2.1' | '2.2';
}
export interface AccessibilityScanOptions {
url: string;
wcagLevel?: WCAGLevel;
viewport?: ViewportSize;
authenticate?: AuthenticationConfig;
includeScreenshots?: boolean;
customRules?: CustomRule[];
scanSubpages?: boolean;
maxDepth?: number;
timeout?: number;
waitForSelector?: string;
excludeSelectors?: string[];
}
export interface ViewportSize {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
}
export interface AuthenticationConfig {
type: 'basic' | 'form' | 'oauth' | 'custom';
credentials?: {
username?: string;
password?: string;
token?: string;
};
loginUrl?: string;
selectors?: {
username?: string;
password?: string;
submit?: string;
};
customScript?: string;
}
export interface CustomRule {
id: string;
selector: string;
tags: string[];
description: string;
help: string;
helpUrl?: string;
severity: ImpactValue;
validator: (element: Element) => boolean | Promise<boolean>;
}
export interface AccessibilityViolation {
id: string;
impact: ImpactValue;
description: string;
help: string;
helpUrl: string;
tags: TagValue[];
nodes: ViolationNode[];
wcagCriteria?: string[];
fixSuggestion?: string;
}
export interface ViolationNode {
html: string;
target: string[];
xpath?: string;
failureSummary?: string;
screenshot?: string;
relatedNodes?: RelatedNode[];
}
export interface RelatedNode {
html: string;
target: string[];
}
export interface AccessibilityScanResult {
url: string;
timestamp: Date;
scanDuration: number;
summary: ScanSummary;
violations: AccessibilityViolation[];
passes: AxeResult[];
incomplete: AxeResult[];
inapplicable: AxeResult[];
pageMetadata: PageMetadata;
wcagCompliance: WCAGCompliance;
}
export interface ScanSummary {
violationCount: number;
passCount: number;
incompleteCount: number;
inapplicableCount: number;
criticalIssues: number;
seriousIssues: number;
moderateIssues: number;
minorIssues: number;
score: number;
}
export interface PageMetadata {
title: string;
url: string;
viewport: ViewportSize;
language?: string;
doctype?: string;
hasLandmarks: boolean;
hasHeadings: boolean;
imagesCount: number;
formsCount: number;
linksCount: number;
}
export interface WCAGCompliance {
level: WCAGLevel;
isCompliant: boolean;
criteriaResults: CriteriaResult[];
overallScore: number;
}
export interface CriteriaResult {
criterion: string;
level: 'A' | 'AA' | 'AAA';
passed: boolean;
violations: string[];
}
export interface ScanJob {
id: string;
websiteId: string;
url: string;
status: 'pending' | 'running' | 'completed' | 'failed';
options: AccessibilityScanOptions;
scheduledAt: Date;
startedAt?: Date;
completedAt?: Date;
error?: string;
resultId?: string;
retryCount: number;
}
export interface Website {
id: string;
name: string;
url: string;
userId: string;
scanSchedule?: ScanSchedule;
lastScanAt?: Date;
complianceScore?: number;
tags?: string[];
active: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface ScanSchedule {
frequency: 'manual' | 'hourly' | 'daily' | 'weekly' | 'monthly';
dayOfWeek?: number;
dayOfMonth?: number;
hour?: number;
timezone?: string;
}
export interface ComplianceReport {
id: string;
websiteId: string;
period: ReportPeriod;
generatedAt: Date;
summary: ReportSummary;
trendsData: TrendData[];
detailedResults: AccessibilityScanResult[];
recommendations: string[];
format: 'pdf' | 'html' | 'json' | 'csv';
}
export interface ReportPeriod {
start: Date;
end: Date;
}
export interface ReportSummary {
averageScore: number;
totalScans: number;
improvementRate: number;
criticalIssuesFixed: number;
newIssuesFound: number;
complianceLevel: WCAGLevel;
}
export interface TrendData {
date: Date;
score: number;
violationCount: number;
passCount: number;
}

View file

@ -0,0 +1,155 @@
import type { ImpactValue } from 'axe-core';
import { IMPACT_SCORES, COMPLIANCE_THRESHOLDS, WCAG_TAGS } from './constants';
import type { AccessibilityScanResult, ScanSummary, WCAGLevel } from './types';
export function calculateComplianceScore(result: AccessibilityScanResult): number {
const { violations, passes } = result;
const totalChecks = violations.length + passes.length;
if (totalChecks === 0) return 100;
let weightedViolations = 0;
violations.forEach(violation => {
const impactScore = IMPACT_SCORES[violation.impact as keyof typeof IMPACT_SCORES] || 50;
weightedViolations += (impactScore / 100) * violation.nodes.length;
});
const score = Math.max(0, 100 - (weightedViolations / totalChecks) * 100);
return Math.round(score * 100) / 100;
}
export function isCompliantWithLevel(score: number, level: WCAGLevel['level']): boolean {
return score >= COMPLIANCE_THRESHOLDS[level] * 100;
}
export function getWCAGTags(level: WCAGLevel): string[] {
const key = `WCAG_${level.version.replace('.', '_')}_${level.level}` as keyof typeof WCAG_TAGS;
return [...(WCAG_TAGS[key] || [])];
}
export function summarizeViolations(violations: AccessibilityScanResult['violations']): Partial<ScanSummary> {
const summary = {
criticalIssues: 0,
seriousIssues: 0,
moderateIssues: 0,
minorIssues: 0,
};
violations.forEach(violation => {
const count = violation.nodes.length;
switch (violation.impact) {
case 'critical':
summary.criticalIssues += count;
break;
case 'serious':
summary.seriousIssues += count;
break;
case 'moderate':
summary.moderateIssues += count;
break;
case 'minor':
summary.minorIssues += count;
break;
}
});
return summary;
}
export function generateFixSuggestion(violationId: string): string {
const suggestions: Record<string, string> = {
'color-contrast': 'Increase the contrast ratio between text and background colors to meet WCAG standards.',
'image-alt': 'Add descriptive alt text to images. Use alt="" for decorative images.',
'label': 'Add labels to form controls using <label> elements or aria-label attributes.',
'button-name': 'Provide accessible names for buttons using text content, aria-label, or aria-labelledby.',
'link-name': 'Ensure links have descriptive text that explains their purpose.',
'heading-order': 'Use heading levels in sequential order without skipping levels.',
'landmark-one-main': 'Ensure the page has one main landmark using <main> or role="main".',
'page-has-heading-one': 'Add a single <h1> element to identify the main content of the page.',
'meta-viewport': 'Avoid disabling zoom by not using maximum-scale=1.0 in viewport meta tag.',
'duplicate-id': 'Ensure all id attributes are unique within the page.',
'aria-required-attr': 'Add all required ARIA attributes for the role being used.',
'aria-valid-attr': 'Use only valid ARIA attributes that are spelled correctly.',
'html-has-lang': 'Add a lang attribute to the <html> element to specify the page language.',
'document-title': 'Provide a descriptive <title> element in the document head.',
'list': 'Ensure <ul> and <ol> elements only contain <li>, <script>, or <template> elements.',
};
return suggestions[violationId] || 'Review the element and ensure it meets accessibility standards.';
}
export function formatViolationTarget(target: string[]): string {
return target.join(' > ');
}
export function estimateFixTime(impact: ImpactValue, nodeCount: number): number {
const baseTime: Record<ImpactValue, number> = {
critical: 30,
serious: 20,
moderate: 15,
minor: 10,
};
return (baseTime[impact] || 15) * nodeCount;
}
export function groupViolationsByWCAGCriteria(
violations: AccessibilityScanResult['violations']
): Record<string, AccessibilityScanResult['violations']> {
const grouped: Record<string, AccessibilityScanResult['violations']> = {};
violations.forEach(violation => {
violation.wcagCriteria?.forEach(criterion => {
if (!grouped[criterion]) {
grouped[criterion] = [];
}
grouped[criterion].push(violation);
});
});
return grouped;
}
export function generateComplianceStatement(result: AccessibilityScanResult): string {
const { wcagCompliance, summary } = result;
const score = result.summary.score;
const level = wcagCompliance.level;
if (wcagCompliance.isCompliant) {
return `This page meets WCAG ${level.version} Level ${level.level} compliance standards with a score of ${score}%.`;
}
const majorIssues = summary.criticalIssues + summary.seriousIssues;
return `This page does not meet WCAG ${level.version} Level ${level.level} compliance. ` +
`Found ${majorIssues} major accessibility issues that need to be addressed. ` +
`Current compliance score: ${score}%.`;
}
export function extractPageMetadata(html: string): Partial<AccessibilityScanResult['pageMetadata']> {
const metadata: Partial<AccessibilityScanResult['pageMetadata']> = {};
// Extract title
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch) {
metadata.title = titleMatch[1].trim();
}
// Extract language
const langMatch = html.match(/<html[^>]*lang=["']([^"']+)["']/i);
if (langMatch) {
metadata.language = langMatch[1];
}
// Count elements
metadata.imagesCount = (html.match(/<img[^>]*>/gi) || []).length;
metadata.formsCount = (html.match(/<form[^>]*>/gi) || []).length;
metadata.linksCount = (html.match(/<a[^>]*>/gi) || []).length;
// Check for landmarks
metadata.hasLandmarks = /<(main|nav|aside|header|footer)[^>]*>|role=["'](main|navigation|complementary|banner|contentinfo)["']/i.test(html);
// Check for headings
metadata.hasHeadings = /<h[1-6][^>]*>/i.test(html);
return metadata;
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"allowJs": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"incremental": true,
"declaration": true,
"declarationMap": true,
"composite": true
},
"exclude": ["node_modules", "dist", ".turbo"]
}

28
apps/wcag-ada/turbo.json Normal file
View file

@ -0,0 +1,28 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "build/**"]
},
"dev": {
"persistent": true,
"cache": false
},
"test": {
"dependsOn": ["build"],
"outputs": [],
"cache": false
},
"lint": {
"outputs": []
},
"typecheck": {
"outputs": []
},
"clean": {
"cache": false
}
}
}

View file

@ -0,0 +1,84 @@
# Build stage
FROM oven/bun:1-alpine as builder
# Install dependencies for Prisma and Playwright
RUN apk add --no-cache openssl chromium
WORKDIR /app
# Copy workspace files
COPY package.json bun.lockb ./
COPY apps/wcag-ada/worker/package.json ./apps/wcag-ada/worker/
COPY apps/wcag-ada/scanner/package.json ./apps/wcag-ada/scanner/
COPY apps/wcag-ada/config/package.json ./apps/wcag-ada/config/
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
COPY lib/service/core-config/package.json ./lib/service/core-config/
COPY lib/service/core-logger/package.json ./lib/service/core-logger/
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source code
COPY apps/wcag-ada/worker ./apps/wcag-ada/worker
COPY apps/wcag-ada/scanner ./apps/wcag-ada/scanner
COPY apps/wcag-ada/config ./apps/wcag-ada/config
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
COPY lib/service/core-config ./lib/service/core-config
COPY lib/service/core-logger ./lib/service/core-logger
COPY tsconfig.json ./
# Generate Prisma client
WORKDIR /app/apps/wcag-ada/worker
RUN bunx prisma generate
# Build the application
RUN bun run build
# Production stage
FROM oven/bun:1-alpine
# Install runtime dependencies including Chromium
RUN apk add --no-cache \
openssl \
chromium \
nss \
freetype \
freetype-dev \
harfbuzz \
ca-certificates \
ttf-freefont \
nodejs \
yarn
# Tell Puppeteer/Playwright to use installed Chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true \
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
WORKDIR /app
# Copy built application
COPY --from=builder /app/apps/wcag-ada/worker/dist ./dist
COPY --from=builder /app/apps/wcag-ada/worker/node_modules ./node_modules
COPY --from=builder /app/apps/wcag-ada/worker/prisma ./prisma
COPY --from=builder /app/apps/wcag-ada/worker/package.json ./
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Set ownership
RUN chown -R nodejs:nodejs /app
USER nodejs
# Expose port for health check
EXPOSE 3002
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
# Start the application
CMD ["bun", "run", "start"]

View file

@ -0,0 +1,29 @@
{
"name": "@wcag-ada/worker",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -b",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@wcag-ada/shared": "workspace:*",
"@wcag-ada/scanner": "workspace:*",
"@wcag-ada/config": "workspace:*",
"bullmq": "^5.1.1",
"ioredis": "^5.3.2",
"@prisma/client": "^5.8.0",
"node-cron": "^3.0.3",
"pino": "^8.17.2",
"pino-pretty": "^10.3.1"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/node-cron": "^3.0.11",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

Some files were not shown because too many files have changed in this diff Show more