diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cf71c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: '1.1.36' + + - name: Install dependencies + run: bun install + + - name: Run type checking + run: bun run typecheck + + - name: Run tests + run: bun test + + - name: Build site + run: bun run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + if: success() + with: + name: dist + path: dist/ + retention-days: 7 diff --git a/package.json b/package.json index fab3cd7..7e875ca 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "start": "astro preview", "typecheck": "tsc --noEmit", "test": "bun test", + "test:coverage": "vitest run --coverage", "build:favicon": "bash scripts/build-favicons.sh", "astro": "astro" }, @@ -26,7 +27,6 @@ "@astrojs/tailwind": "^6.0.2", "@heroicons/react": "^2.2.0", "@radix-ui/react-tabs": "^1.1.12", - "add": "^2.0.6", "astro": "^5.11.0", "autoprefixer": "^10.4.21", "bun": "^1.2.18", @@ -39,7 +39,7 @@ "sharp": "^0.33.5", "shiki": "^3.7.0", "tailwind-merge": "^2.6.0", - "tailwindcss": "3.3.6" + "tailwindcss": "^3.4.17" }, "devDependencies": { "@biomejs/biome": "^1.9.4", @@ -60,7 +60,8 @@ "postcss": "^8.5.6", "puppeteer": "^24.12.1", "typescript": "^5.8.3", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4" }, "engines": { "node": "22.12.0", diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..126fc07 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +import { Component, type ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("ErrorBoundary caught an error:", error, errorInfo); + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+

+ Something went wrong +

+

+ We're sorry, but something unexpected happened. Please try refreshing the page. +

+ {this.state.error && ( +
+ + Error details + +
+									{this.state.error.message}
+								
+
+ )} + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 25ba020..ba8921c 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -34,6 +34,12 @@ const { title = "Oliver Steele", description = "Making, teaching, writing, playi + + Skip to main content +
@@ -73,7 +79,7 @@ const { title = "Oliver Steele", description = "Making, teaching, writing, playi
-
+
diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..0ec6d90 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,29 @@ +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware(async (context, next) => { + const response = await next(); + + // Add Content-Security-Policy header + // Note: Adjust these directives based on your specific needs + const csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-inline/eval needed for Astro's client-side scripts + "style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Tailwind and inline styles + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "); + + response.headers.set("Content-Security-Policy", csp); + + // Add other security headers + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); + + return response; +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0af5975..868292b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,5 +12,17 @@ export default defineConfig({ }, // Increase timeout for integration tests but not too much testTimeout: 30000, + // Coverage configuration + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.test.ts', + '**/*.config.*', + '**/types/**', + ], + }, }, });