diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 774a9b6..4cb5547 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,9 +1,21 @@ +{"id":"META-02m","title":"Add skip link for keyboard navigation","description":"Add a \"Skip to main content\" link at the top of the page that becomes visible on focus. Standard accessibility requirement for keyboard users to bypass navigation. Add to app/layout.tsx or app/(prose)/layout.tsx.","design":"```tsx\n// In app/(prose)/layout.tsx, add as first child of the container:\n\u003ca href=\"#main\" className=\"sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-accent focus:text-black focus:rounded\"\u003e\n Skip to main content\n\u003c/a\u003e\n\n// Add id=\"main\" to \u003cmain\u003e elements in page components\n```","acceptance_criteria":"- Skip link is first focusable element in DOM\n- Hidden by default (sr-only)\n- Becomes visible on focus\n- Links to #main which exists on main element\n- Keyboard users can tab to it and skip to main content","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-21T14:12:16.351028-05:00","updated_at":"2025-12-21T14:43:54.967605-05:00","closed_at":"2025-12-21T14:43:54.967605-05:00","close_reason":"Added skip link to prose layout and id=\"main\" to all main elements. Keyboard users can now skip directly to main content.","dependencies":[{"issue_id":"META-02m","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.604904-05:00","created_by":"daemon"}]} +{"id":"META-160","title":"Add vitest-axe for automated a11y testing","description":"Install vitest-axe and add a11y checks to component tests. Setup: add matchers to vitest.setup.ts, add toHaveNoViolations() assertions to key page tests. This provides automated catching of ARIA issues, heading hierarchy problems, etc.","design":"```bash\nnpm install -D vitest-axe\n```\n\n```ts\n// vitest.setup.ts - add:\nimport * as matchers from 'vitest-axe/matchers'\nexpect.extend(matchers)\n```\n\n```ts\n// In component tests:\nconst { container } = render(jsx)\nexpect(container).toHaveNoViolations()\n```\n\nAdd to: Home page test, Blog page test, Likes page test.","acceptance_criteria":"- vitest-axe installed as dev dependency\n- Matchers extended in vitest.setup.ts\n- At least Home, Blog, and Likes page tests include toHaveNoViolations()\n- npm run test passes with axe checks\n- Any violations found are either fixed or documented as known issues","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-21T14:12:16.889302-05:00","updated_at":"2025-12-21T14:39:20.943848-05:00","dependencies":[{"issue_id":"META-160","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.94008-05:00","created_by":"daemon"}]} +{"id":"META-5nf","title":"Add Lighthouse CI for automated a11y, performance, and SEO checks","description":"Add Lighthouse CI to the build process to automatically catch contrast failures, performance issues, and SEO problems. Runs against the static export.\n\nSetup:\n1. Install @lhci/cli as dev dependency\n2. Add lighthouserc.js config\n3. Add npm script for local runs\n4. Add GitHub Action for CI\n\nThis ensures contrast and other a11y issues are caught automatically on every PR.","design":"```bash\nnpm install -D @lhci/cli\n```\n\n```js\n// lighthouserc.js\nmodule.exports = {\n ci: {\n collect: {\n staticDistDir: './out',\n url: ['/', '/blog/', '/likes/'],\n },\n assert: {\n assertions: {\n 'categories:accessibility': ['error', { minScore: 0.9 }],\n 'categories:performance': ['warn', { minScore: 0.8 }],\n 'categories:seo': ['warn', { minScore: 0.9 }],\n },\n },\n upload: { target: 'temporary-public-storage' },\n },\n};\n```\n\n```json\n// package.json scripts\n\"lighthouse\": \"lhci autorun\"\n```\n\nGitHub Action: Create `.github/workflows/lighthouse.yml` to run on PRs after build.","acceptance_criteria":"- @lhci/cli installed as dev dependency\n- lighthouserc.js configured for static export (out/ directory)\n- npm run lighthouse script works locally\n- GitHub Action runs Lighthouse on PRs\n- Contrast failures cause CI to fail (a11y score threshold)\n- Performance, SEO, best practices scores visible","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-21T14:34:10.991579-05:00","updated_at":"2025-12-21T14:39:20.907599-05:00","dependencies":[{"issue_id":"META-5nf","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:34:27.815182-05:00","created_by":"daemon"}]} {"id":"META-64r","title":"Add automated metadata validation/testing","description":"Create automated validation for social sharing metadata to replace manual checking via metatags.io, Twitter card validator, etc.\n\n**IMPORTANT: This should NOT be vitest-based tests. Use browser automation (Playwright) to validate what users will actually see when sharing links on Twitter, LinkedIn, etc.**\n\nOptions to explore:\n1. Playwright tests that render pages in actual browser and validate metadata tags in the rendered DOM\n2. Playwright tests that capture screenshots of social preview cards\n3. Integration with Open Graph debugger APIs if available\n4. Schema validation for JSON-LD structured data in rendered pages\n\nRequirements:\n- Validate all pages have required OG tags (og:title, og:description, og:image, og:url) in rendered HTML\n- Validate Twitter card tags (twitter:card, twitter:title, twitter:description, twitter:image) in rendered HTML\n- Validate image URLs are accessible and have correct dimensions (1200x630 for OG images)\n- Validate JSON-LD schema correctness in rendered pages\n- Fail build on missing/invalid metadata\n\nGoal: Catch metadata issues at build time by validating the actual rendered output, not just the Next.js metadata API output.","status":"closed","priority":4,"issue_type":"task","created_at":"2025-12-20T21:10:37.66262-05:00","updated_at":"2025-12-20T21:19:46.260153-05:00","closed_at":"2025-12-20T21:19:46.260153-05:00","close_reason":"Deprioritized - will implement later with browser-based validation (Playwright) instead of vitest. Updated description to clarify approach.","dependencies":[{"issue_id":"META-64r","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-20T21:10:45.462965-05:00","created_by":"daemon"},{"issue_id":"META-64r","depends_on_id":"META-r0u","type":"blocks","created_at":"2025-12-20T21:10:45.489372-05:00","created_by":"daemon"},{"issue_id":"META-64r","depends_on_id":"META-9gb","type":"blocks","created_at":"2025-12-20T21:10:45.511877-05:00","created_by":"daemon"}]} {"id":"META-84g","title":"Create shared metadata constants","description":"Create `utils/metadata.ts` with:\n- SITE_NAME, SITE_DESCRIPTION, SITE_AUTHOR constants\n- DEFAULT_OG_IMAGE path\n- Reuse existing SITE_URL from constants.ts","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-18T23:49:38.505975-05:00","updated_at":"2025-12-20T20:51:24.399216-05:00","closed_at":"2025-12-20T20:51:24.399216-05:00","close_reason":"Completed","dependencies":[{"issue_id":"META-84g","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-18T23:49:55.169926-05:00","created_by":"daemon"}]} +{"id":"META-86l","title":"Add focus-visible styles to error page retry button","description":"Button in app/likes/error.tsx:18-21 has hover state but no focus styles. Add focus-visible styles for keyboard accessibility.","acceptance_criteria":"- Retry button has visible focus state matching hover state styling\n- Keyboard users see clear focus indicator when tabbing to button","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-21T14:12:16.557724-05:00","updated_at":"2025-12-21T14:45:11.71373-05:00","closed_at":"2025-12-21T14:45:11.71373-05:00","close_reason":"Added focus-visible:bg-accent/90, focus-visible:outline-none, focus-visible:ring-2, and focus-visible:ring-zinc-300 to retry button. Keyboard users now see clear focus indicator matching hover state.","dependencies":[{"issue_id":"META-86l","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.698975-05:00","created_by":"daemon"}]} {"id":"META-9gb","title":"Add JSON-LD structured data for blog posts","description":"Add Article schema to blog posts:\n- @type: Article\n- headline, description, datePublished, dateModified\n- author Person schema\n- url\n\nAdd via script tag with dangerouslySetInnerHTML in the Post component or page.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-18T23:49:40.524604-05:00","updated_at":"2025-12-20T21:16:01.063448-05:00","closed_at":"2025-12-20T21:16:01.063448-05:00","close_reason":"Completed: Added JSON-LD Article schema to blog posts with comprehensive tests","dependencies":[{"issue_id":"META-9gb","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-18T23:49:57.0336-05:00","created_by":"daemon"},{"issue_id":"META-9gb","depends_on_id":"META-caw","type":"blocks","created_at":"2025-12-18T23:50:12.246159-05:00","created_by":"daemon"}]} {"id":"META-c4g","title":"Create default OG image","description":"Create `public/og-default.png`:\n- 1200x630px image with site branding\n- Used as fallback for pages without custom OG images\n- Simple design: name, maybe tagline, solid background","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-18T23:49:41.062507-05:00","updated_at":"2025-12-20T22:05:41.572231-05:00","closed_at":"2025-12-20T22:05:41.572231-05:00","close_reason":"Completed: Created default OG image (1200x630px) with photo, name, and accent color using satori/sharp generation script","dependencies":[{"issue_id":"META-c4g","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-18T23:49:57.513751-05:00","created_by":"daemon"}]} {"id":"META-c6a","title":"Improve site metadata for SEO and social sharing","description":"Add comprehensive metadata across the site including OpenGraph, Twitter cards, canonical URLs, and JSON-LD structured data.","status":"closed","priority":2,"issue_type":"epic","created_at":"2025-12-18T23:49:17.544203-05:00","updated_at":"2025-12-20T23:25:54.408667-05:00","closed_at":"2025-12-20T23:25:54.408667-05:00","close_reason":"All metadata work complete: Added comprehensive OG/Twitter metadata across all pages, created default OG image, added JSON-LD structured data, and implemented automated validation in CI. All social platforms (Twitter/X, Facebook, LinkedIn) now fully supported."} {"id":"META-caw","title":"Add generateMetadata for blog posts","description":"Update `app/(prose)/[slug]/page.tsx` with generateMetadata function:\n- Dynamic title from post.title\n- Description from post.description\n- Canonical URL\n- OpenGraph: type 'article', publishedTime, modifiedTime, authors\n- OG image: use post.featuredImage when available, fall back to default\n- Twitter card with same image logic\n\nAlso clean up commented-out ArticleSeo component.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-18T23:49:40.002137-05:00","updated_at":"2025-12-20T21:07:33.751521-05:00","closed_at":"2025-12-20T21:07:33.751521-05:00","close_reason":"Completed","dependencies":[{"issue_id":"META-caw","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-18T23:49:56.562868-05:00","created_by":"daemon"},{"issue_id":"META-caw","depends_on_id":"META-84g","type":"blocks","created_at":"2025-12-18T23:50:11.796443-05:00","created_by":"daemon"}]} +{"id":"META-e3h","title":"Add aria-label to pagination nav","description":"Pagination nav in ui/nav/pagination.tsx:12 has no aria-label, making it indistinguishable from primary navigation for screen readers. Add aria-label=\"Pagination\" or similar.","acceptance_criteria":"- Pagination nav has aria-label=\"Pagination\" or \"Post navigation\"\n- Screen readers can distinguish pagination from primary navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T14:12:16.616902-05:00","updated_at":"2025-12-21T14:45:47.123057-05:00","closed_at":"2025-12-21T14:45:47.123057-05:00","close_reason":"Added aria-label=\"Post navigation\" to pagination nav. Screen readers can now distinguish pagination from primary navigation.","dependencies":[{"issue_id":"META-e3h","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.747102-05:00","created_by":"daemon"}]} +{"id":"META-eww","title":"Fix flex_auto typo in post.tsx","description":"Change `className=\"flex_auto\"` to `className=\"flex-auto\"` in app/(prose)/[slug]/ui/post.tsx:16. This is a bug causing the main element not to expand properly in flex container.","acceptance_criteria":"- className changed from \"flex_auto\" to \"flex-auto\"\n- Post pages render with footer pushed to bottom on short content\n- Existing test at app/(prose)/page.test.tsx:102 pattern works for posts too","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-12-21T14:12:16.295497-05:00","updated_at":"2025-12-21T14:41:27.415361-05:00","closed_at":"2025-12-21T14:41:27.415361-05:00","close_reason":"Fixed typo: className=\"flex_auto\" → className=\"flex-auto\" in post.tsx:16. All tests pass.","dependencies":[{"issue_id":"META-eww","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.572544-05:00","created_by":"daemon"}]} +{"id":"META-ns8","title":"Fix contrast issues flagged by Lighthouse CI","description":"After Lighthouse CI is set up (META-5nf), fix any contrast issues it flags.\n\nPre-verified issues (zinc-500: 3.67:1, zinc-600: 2.29:1 fail WCAG AA):\n- syntax-highlighting.css:58 - line numbers\n- globals.css:115 - captions\n- app/likes/error.tsx - error ID\n\nFix by changing to text-zinc-400 (6.91:1 passes AA).","acceptance_criteria":"- Lighthouse CI a11y score passes (no contrast failures)\n- All flagged contrast issues resolved\n- Visual verification styling still looks good","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T14:12:16.750204-05:00","updated_at":"2025-12-21T14:34:27.845813-05:00","dependencies":[{"issue_id":"META-ns8","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.850586-05:00","created_by":"daemon"},{"issue_id":"META-ns8","depends_on_id":"META-5nf","type":"blocks","created_at":"2025-12-21T14:34:27.882908-05:00","created_by":"daemon"}]} +{"id":"META-pnf","title":"Add focus-visible styles to Likes media links","description":"Media item links in app/likes/page.tsx:80 use raw `\u003ca\u003e` with no focus styling. Add focus-visible styles consistent with Card component.","acceptance_criteria":"- Media links have visible focus state consistent with Card\n- Keyboard navigation shows visible focus when tabbing through media items","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-21T14:12:16.464657-05:00","updated_at":"2025-12-21T14:44:40.432063-05:00","closed_at":"2025-12-21T14:44:40.432063-05:00","close_reason":"Added focus-visible:outline-none, focus-visible:ring-2, and focus-visible:ring-zinc-300 to media item links. Keyboard navigation now shows visible focus ring around media items.","dependencies":[{"issue_id":"META-pnf","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.66331-05:00","created_by":"daemon"}]} {"id":"META-r0u","title":"Add metadata to static pages","description":"Update static pages:\n- `app/(prose)/blog/page.tsx`: Add metadata export with title and description\n- `app/likes/page.tsx`: Simplify to use title template (remove manual suffix)","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-18T23:49:39.524433-05:00","updated_at":"2025-12-20T21:13:30.789817-05:00","closed_at":"2025-12-20T21:13:30.789817-05:00","close_reason":"Completed: Added metadata exports to blog and likes pages with comprehensive tests","dependencies":[{"issue_id":"META-r0u","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-18T23:49:56.078553-05:00","created_by":"daemon"},{"issue_id":"META-r0u","depends_on_id":"META-xhu","type":"blocks","created_at":"2025-12-18T23:50:11.329502-05:00","created_by":"daemon"}]} +{"id":"META-sh8","title":"Add focus-visible styles to Card component","description":"Card component (ui/card.tsx) has hover styles but no focus styles. Add focus-visible:border-zinc-300 focus-visible:outline-none (or match the link glow style). Used by pagination links.","design":"```tsx\n// ui/card.tsx - add focus-visible styles:\nclassName=\"... hover:border-zinc-300 focus-visible:border-zinc-300 focus-visible:outline-none\"\n```","acceptance_criteria":"- Card has focus-visible:border-zinc-300 or equivalent visible focus state\n- focus-visible:outline-none to remove browser default\n- Keyboard navigation shows visible focus on pagination cards","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-21T14:12:16.406322-05:00","updated_at":"2025-12-21T14:44:15.566601-05:00","closed_at":"2025-12-21T14:44:15.566601-05:00","close_reason":"Added focus-visible:border-zinc-300 and focus-visible:outline-none to Card component. Keyboard navigation now shows visible focus state matching hover style.","dependencies":[{"issue_id":"META-sh8","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.634187-05:00","created_by":"daemon"}]} +{"id":"META-sth","title":"Add prefers-reduced-motion support to loading skeleton","description":"Loading skeleton in app/likes/loading.tsx uses animate-pulse which runs for all users. Add motion-reduce:animate-none to respect user preferences for reduced motion.","design":"```tsx\n// Change from:\nclassName=\"animate-pulse ...\"\n\n// To:\nclassName=\"animate-pulse motion-reduce:animate-none ...\"\n```","acceptance_criteria":"- Loading skeleton elements have motion-reduce:animate-none\n- Users with prefers-reduced-motion see static skeleton instead of pulsing","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-21T14:12:16.685051-05:00","updated_at":"2025-12-21T14:39:20.986853-05:00","dependencies":[{"issue_id":"META-sth","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.796009-05:00","created_by":"daemon"}]} {"id":"META-val","title":"Add automated metadata validation/testing","description":"Create automated validation for social sharing metadata to replace manual checking via metatags.io, Twitter card validator, etc.\n\n**IMPORTANT: This should NOT be vitest-based tests. Use browser automation (Playwright) to validate what users will actually see when sharing links on Twitter, LinkedIn, etc.**\n\nOptions to explore:\n1. Playwright tests that render pages in actual browser and validate metadata tags in the rendered DOM\n2. Playwright tests that capture screenshots of social preview cards\n3. Integration with Open Graph debugger APIs if available\n4. Schema validation for JSON-LD structured data in rendered pages\n\nRequirements:\n- Validate all pages have required OG tags (og:title, og:description, og:image, og:url) in rendered HTML\n- Validate Twitter card tags (twitter:card, twitter:title, twitter:description, twitter:image) in rendered HTML\n- Validate image URLs are accessible and have correct dimensions (1200x630 for OG images)\n- Validate JSON-LD schema correctness in rendered pages\n- Fail build on missing/invalid metadata\n\nGoal: Catch metadata issues at build time by validating the actual rendered output, not just the Next.js metadata API output.","status":"closed","priority":4,"issue_type":"task","created_at":"2025-12-20T21:21:09.650384-05:00","updated_at":"2025-12-20T23:25:48.648652-05:00","closed_at":"2025-12-20T23:25:48.648652-05:00","close_reason":"Validation complete: Implemented automated HTML parsing with Cheerio to validate all OG tags (including og:site_name, og:locale) and Twitter Card tags (including twitter:creator) in rendered build output. Validates image accessibility and dimensions (1200x630). Runs in CI after build. All pages passing validation.","dependencies":[{"issue_id":"META-val","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-20T21:21:09.79859-05:00","created_by":"daemon"}]} +{"id":"META-w4k","title":"Pre-deploy polish: accessibility, focus styles, and CI checks","description":"Full polish pass before deploying portfolio site. Addresses accessibility gaps, focus visibility, contrast issues, and adds automated a11y testing to CI.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-12-21T14:11:43.114533-05:00","updated_at":"2025-12-21T14:11:43.114533-05:00"} +{"id":"META-w5f","title":"Remove duplicate @utility bg-accent in globals.css","description":"globals.css has duplicate @utility bg-accent blocks at lines 30-32 and 34-36. Remove the duplicate. Minor cleanup.","acceptance_criteria":"- Only one @utility bg-accent block in globals.css\n- Build still succeeds\n- Accent background color still works","status":"open","priority":3,"issue_type":"chore","created_at":"2025-12-21T14:12:16.8157-05:00","updated_at":"2025-12-21T14:28:22.403272-05:00","dependencies":[{"issue_id":"META-w5f","depends_on_id":"META-w4k","type":"parent-child","created_at":"2025-12-21T14:12:33.894201-05:00","created_by":"daemon"}]} {"id":"META-xhu","title":"Enhance root layout metadata","description":"Update `app/layout.tsx` with:\n- metadataBase: new URL(SITE_URL)\n- Title template: { default: 'Michael Uloth', template: '%s | Michael Uloth' }\n- authors, creator fields\n- Default OpenGraph config (type: 'website', locale, siteName, images)\n- Default Twitter card config (card: 'summary_large_image', creator: '@ooloth')","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-18T23:49:39.011289-05:00","updated_at":"2025-12-20T20:56:36.587689-05:00","closed_at":"2025-12-20T20:56:36.587689-05:00","close_reason":"Completed","dependencies":[{"issue_id":"META-xhu","depends_on_id":"META-c6a","type":"parent-child","created_at":"2025-12-18T23:49:55.635623-05:00","created_by":"daemon"},{"issue_id":"META-xhu","depends_on_id":"META-84g","type":"blocks","created_at":"2025-12-18T23:50:10.889686-05:00","created_by":"daemon"}]} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index deebfca..13357b6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,4 +1,3 @@ { - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": ["beads"] + "enableAllProjectMcpServers": true } diff --git a/.claude/specs/build-integration-tests-plan.md b/.claude/specs/build-integration-tests-plan.md deleted file mode 100644 index e240339..0000000 --- a/.claude/specs/build-integration-tests-plan.md +++ /dev/null @@ -1,319 +0,0 @@ -# Build Integration Tests Plan - -**Date**: 2025-12-13 -**Status**: Planning -**Branch**: To be implemented after `fill-testing-gaps` is merged - -## Overview - -This plan adds **build integration tests** that verify the actual Next.js build process succeeds. Unlike unit tests that mock dependencies, these tests run `next build` and validate the output. - -## Goals - -1. **Verify builds succeed**: Ensure `next build` completes without errors -2. **Catch build-time failures**: Detect issues that only appear during actual builds -3. **Validate static generation**: Confirm all expected pages are pre-rendered -4. **Monitor build warnings**: Ensure no unexpected warnings appear -5. **Test cache invalidation**: Verify `nocache=true` query param works - -## Non-Goals - -- Not replacing unit tests (those stay - faster feedback loop) -- Not testing runtime behavior (that's E2E testing) -- Not running on every commit (too slow - run in CI only) - -## Implementation Strategy - -### 1. Test Framework Setup - -**File**: `tests/build/setup.ts` - -Create a test harness that: - -- Runs `next build` in a child process -- Captures stdout/stderr -- Parses build output for errors/warnings -- Validates `.next` directory structure -- Cleans up after tests - -**Dependencies needed**: - -```json -{ - "execa": "^8.0.0" // For running shell commands with proper stdio handling -} -``` - -### 2. Core Build Tests - -**File**: `tests/build/next-build.test.ts` - -#### Test Cases: - -1. **Build succeeds without errors** - - Run `next build` - - Assert exit code is 0 - - Assert no error messages in output - -2. **All expected pages are generated** - - Verify `.next/server/app/(prose)/[slug]/` contains HTML files - - Verify `.next/server/app/(prose)/blog.html` exists - - Verify `.next/server/app/likes.html` exists - - Count matches expected number of blog posts - -3. **Static params generate all routes** - - Parse build output for "generating static pages" - - Verify count matches number of posts - - Verify specific known slugs appear - -4. **No unexpected build warnings** - - Parse stdout for warning patterns - - Allow known/acceptable warnings (if any) - - Fail on unexpected warnings (e.g., missing env vars, deprecated APIs) - -5. **Build artifacts are valid** - - Verify `.next/BUILD_ID` exists and is non-empty - - Verify `.next/required-server-files.json` exists - - Verify client bundles exist in `.next/static/` - -### 3. Cache Invalidation Tests - -**File**: `tests/build/cache-behavior.test.ts` - -#### Test Cases: - -1. **Cache is used in development** - - Mock Notion API responses - - Run two builds - - Verify second build uses cached data (faster) - - Verify cache files exist in `.local-cache/` - -2. **Cache is bypassed in production** - - Set NODE_ENV=production - - Verify no cache reads/writes - - Verify `.local-cache/` is not used - -3. **skipCache query param works** - - Test would need to verify builds with different cache behavior - - (This might be more of a runtime test - consider scope) - -### 4. Error Scenario Tests - -**File**: `tests/build/error-handling.test.ts` - -#### Test Cases: - -1. **Build fails when API returns error** - - Mock Notion API to return 500 - - Run build - - Assert build fails with helpful error message - - Verify error shows which API failed - -2. **Build fails when validation fails** - - Mock Notion API to return invalid data - - Run build - - Assert build fails with validation error - - Verify error shows which field failed validation - -3. **Build fails when env vars missing** - - Unset required env var - - Run build - - Assert build fails with clear error about missing env var - -4. **Build fails when getPost returns null for invalid slug** - - This should trigger `notFound()` which is expected - - Build should still succeed - - (This validates the happy path of error handling) - -### 5. Performance Monitoring (Optional) - -**File**: `tests/build/performance.test.ts` - -#### Test Cases: - -1. **Build completes within reasonable time** - - Run build with timer - - Assert completes within threshold (e.g., 2 minutes) - - Track build time in CI for regression detection - -2. **Parallel API calls work efficiently** - - Monitor that likes page fetches all 5 APIs in parallel - - Verify build time reflects parallelization - -### 6. CI Integration - -**File**: `.github/workflows/build-tests.yml` - -```yaml -name: Build Integration Tests - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - build-test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run unit tests - run: npm test -- --run - - - name: Run build integration tests - run: npm run test:build - env: - NODE_ENV: production - NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} - NOTION_BLOG_DATABASE_ID: ${{ secrets.NOTION_BLOG_DATABASE_ID }} - # ... all required env vars - - - name: Upload build artifacts on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: build-output - path: .next/ -``` - -**Note**: This requires secrets to be set in GitHub repository settings. - -## Test Isolation Strategy - -### Challenge: External API Dependencies - -Build tests call real APIs (Notion, TMDB, iTunes). Options: - -**Option A: Use Real APIs (Recommended for first iteration)** - -- Pros: Tests real integration, catches API changes -- Cons: Slower, requires API credentials in CI, subject to rate limits -- Mitigation: Run less frequently (only on PR/main), use caching where possible - -**Option B: Mock at Network Level** - -- Use MSW (Mock Service Worker) or nock to intercept HTTP requests -- Pros: Fast, deterministic, no API credentials needed -- Cons: Doesn't catch API changes, more setup overhead - -**Option C: Hybrid Approach** - -- Unit tests: Mock everything (fast feedback) -- Build tests: Real APIs (comprehensive validation) -- Best of both worlds - -**Recommendation**: Start with Option A for simplicity. If builds become too slow or flaky, move to Option C. - -## Directory Structure - -``` -tests/ -├── build/ -│ ├── setup.ts # Build harness utilities -│ ├── next-build.test.ts # Core build tests -│ ├── cache-behavior.test.ts -│ ├── error-handling.test.ts -│ └── performance.test.ts # Optional -├── fixtures/ # Mock data if needed -└── README.md # How to run build tests -``` - -## NPM Scripts - -Add to `package.json`: - -```json -{ - "scripts": { - "test": "vitest", - "test:unit": "vitest --run", - "test:build": "NODE_ENV=test vitest --run tests/build", - "test:all": "npm run test:unit && npm run test:build" - } -} -``` - -## Implementation Order - -1. **Phase 1: Basic Setup** (1-2 hours) - - Install dependencies (execa) - - Create test harness in `tests/build/setup.ts` - - Write first test: "build succeeds without errors" - - Verify it runs and passes - -2. **Phase 2: Core Validation** (2-3 hours) - - Test all expected pages are generated - - Test static params generate all routes - - Test no unexpected warnings - - Test build artifacts are valid - -3. **Phase 3: Error Scenarios** (2-3 hours) - - Test build fails when API returns error - - Test build fails when validation fails - - Test build fails when env vars missing - -4. **Phase 4: CI Integration** (1 hour) - - Create GitHub workflow - - Add secrets to repository - - Verify workflow runs on PR - -5. **Phase 5: Polish** (1 hour) - - Add README documentation - - Update analysis document - - Add performance monitoring (optional) - -**Total estimated effort**: 6-10 hours - -## Success Criteria - -- [ ] Can run `npm run test:build` locally -- [ ] Build tests pass with real API calls -- [ ] Build tests fail appropriately when errors injected -- [ ] CI runs build tests on every PR -- [ ] Build test failures provide actionable error messages -- [ ] Documentation explains how to run and interpret build tests - -## Edge Cases to Consider - -1. **Rate Limiting**: TMDB/iTunes APIs may rate limit in CI - - Mitigation: Add retry logic, use caching, or mock these specific APIs - -2. **Notion API Pagination**: If blog grows beyond 100 posts - - Mitigation: Already handled in code, but verify build doesn't timeout - -3. **Build Output Size**: Large blogs may produce many files - - Mitigation: Add cleanup step to CI, monitor disk usage - -4. **Flaky Tests**: Network issues may cause intermittent failures - - Mitigation: Add retries, better error messages, consider mocking flaky APIs - -5. **Parallel Builds**: Multiple builds running simultaneously may conflict - - Mitigation: Use separate output directories per test run - -## Future Enhancements - -After initial implementation, consider: - -1. **Visual Regression Tests**: Screenshot generated pages, compare to baseline -2. **Performance Benchmarks**: Track build time trends over time -3. **Bundle Size Monitoring**: Alert if bundle size increases significantly -4. **Lighthouse Scores**: Run Lighthouse on generated pages -5. **Link Checking**: Verify all internal links resolve correctly - -## References - -- [Next.js Build Output](https://nextjs.org/docs/app/api-reference/next-config-js/output) -- [Vitest Node Test Examples](https://vitest.dev/guide/environment.html#test-environment) -- [GitHub Actions - Artifacts](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) diff --git a/.claude/specs/build-time-testing-gaps-analysis.md b/.claude/specs/build-time-testing-gaps-analysis.md deleted file mode 100644 index eaeb003..0000000 --- a/.claude/specs/build-time-testing-gaps-analysis.md +++ /dev/null @@ -1,328 +0,0 @@ -# Build-Time Testing Gaps Analysis - -**Date**: 2025-12-13 -**Status**: Reference document - -## Overview - -This document identifies testing gaps in the build-time logic of the Next.js application. Build-time logic includes static page generation, data fetching, image processing, and validation that occurs during `next build`. - -## Major Testing Gaps - -### 1. `generateStaticParams` is completely untested ❌ CRITICAL - -**Location**: `app/(prose)/[slug]/page.tsx:97-101` - -**What it does**: - -- Queries Notion API to fetch all posts -- Maps them to slug params for static page generation -- Critical build-time function that determines which pages Next.js pre-renders - -**Current test coverage**: None - -**Risks**: - -- If `getPosts` returns an error, the build will crash with no test coverage -- No validation that slugs are properly formatted -- No test ensuring the function returns the correct shape for Next.js - -**What should be tested**: - -- Returns array of `{ slug: string }` objects -- Handles `getPosts` error gracefully (or crashes intentionally) -- Handles empty posts list (returns `[]`) -- Handles posts with invalid/missing slugs -- Sort direction is 'ascending' (consistent with current implementation) - -### 2. Build-time rendering logic in page components is untested ❌ HIGH - -**Locations**: - -- `app/(prose)/[slug]/page.tsx` (blog post pages) -- `app/(prose)/blog/page.tsx` (blog index) -- `app/likes/page.tsx` (likes page with multiple API calls) - -**What they do**: - -- Fetch data during build via Server Components -- Call external APIs (Notion, TMDB, iTunes) -- Handle errors and transform data for rendering - -**Current test coverage**: Only `app/likes/page.test.tsx` which tests the `fetchItunesMedia` helper, but not the actual page component - -**What should be tested**: - -- Blog index page fetches and displays posts correctly -- Dynamic blog post page fetches post with blocks and navigation -- Dynamic blog post page returns `notFound()` when slug doesn't exist -- Likes page fetches all media types in parallel -- All pages handle API errors gracefully during build -- `skipCache` query param works correctly (`nocache=true`) - -### 3. Image placeholder generation in production builds is untested ⚠️ MEDIUM - -**Location**: `utils/getImagePlaceholderForEnv.test.ts` - -**What's tested**: - -- ✅ Development mode (returns gray pixel) -- ✅ Production mode (mocked plaiceholder) -- ✅ Parameter validation - -**What's NOT tested**: - -- Actual image fetching and buffer conversion (tests mock `fetch` but don't verify the real flow) -- Real plaiceholder integration (the library is mocked, so you don't know if it actually works with real images) -- Error handling when image URL is invalid (404, network error, etc.) -- Error handling when plaiceholder fails (corrupted image, unsupported format) - -**Impact on build**: If plaiceholder fails in production during build, it could crash the entire build with no test coverage to catch it - -### 4. Notion block fetching is completely untested ❌ HIGH - -**Location**: `lib/notion/getBlockChildren.ts` - -**What it does**: - -- Fetches child blocks for blog post content -- Recursively fetches nested blocks -- Transforms and validates block data -- Critical for rendering blog post content - -**Current test coverage**: None (only integration test that mocks it) - -**What should be tested**: - -- Fetches blocks for a valid page ID -- Handles pagination for pages with many blocks -- Recursively fetches child blocks -- Validates block structure with Zod -- Returns `Err` when API call fails -- Handles empty blocks list -- Properly groups blocks by type - -### 5. Zod validation error handling at build time is partially tested ⚠️ MEDIUM - -**What's tested**: Most API fetchers test that validation errors throw/return `Err` - -**What's NOT tested**: - -- Build-time behavior when validation fails - you test that functions return `Err`, but not what happens during actual Next.js build -- Logging of validation errors - `logValidationError` utility is called everywhere but never tested -- Whether builds abort or continue when validation fails - critical for catching data issues - -### 6. Cache behavior during builds is partially tested ⚠️ LOW - -**Location**: `lib/cache/filesystem.test.ts` - -**What's tested**: - -- ✅ Cache is disabled in production -- ✅ Cache read/write in development -- ✅ Schema validation - -**What's NOT tested**: - -- Behavior during Next.js production build - does the cache get used? Ignored? Cleared? -- Cache key collisions - what happens if two different requests use the same sanitized key? -- Cache directory initialization - what if `.local-cache` doesn't exist yet? -- Concurrent cache writes - build parallelization could cause race conditions - -### 7. Cloudinary image transformation in build is untested ❌ MEDIUM - -**Location**: `lib/cloudinary/transformCloudinaryImage.ts` - -**Current test coverage**: None - -**What it does**: Transforms Cloudinary URLs during build for TMDB/iTunes images - -**What should be tested**: - -- Transforms image URL with correct parameters -- Handles URLs that are already transformed -- Handles invalid Cloudinary URLs -- Returns correct format for Next.js Image component - -### 8. Media item fetching integration is untested ❌ MEDIUM - -**Location**: `lib/notion/getMediaItems.ts` - -**Current test coverage**: None (only used in tests via mocks) - -**What it does**: - -- Fetches books, albums, podcasts from Notion -- Filters and transforms media data -- Used by likes page during build - -**What should be tested**: Same pattern as `getPosts`/`getPost` tests - -### 9. Build environment variable validation is untested ❌ LOW - -**Location**: `lib/env.ts` - -**What's tested**: None - -**What it does**: - -- Validates all required env vars with Zod -- Used throughout build-time logic - -**What should be tested**: - -- All required env vars are present -- Validation fails with helpful errors for missing vars -- Validation fails for invalid formats (e.g., non-numeric IDs) -- Can detect when running in different environments - -### 10. Build-time error reporting and recovery is untested ⚠️ MEDIUM - -**Locations**: Throughout the codebase - -**Patterns used**: - -- `Result` for explicit error handling -- `.unwrap()` calls that throw on error -- Try-catch blocks - -**What's NOT tested**: - -- What happens when `.unwrap()` is called during build? - Does Next.js show the error? Crash gracefully? Continue? -- Error message quality - Are build errors actionable for developers? -- Partial build success - If TMDB succeeds but iTunes fails, what happens? - -## Test Coverage Summary - -| Build-Time Component | Unit Tests | Integration Tests | E2E/Build Tests | -| ----------------------------------- | --------------------------------------------- | ----------------- | --------------- | -| `generateStaticParams` | ✅ **Complete** (6 tests) | ❌ None | ❌ None | -| Page components (Server Components) | ✅ **Excellent** (32 tests - all major pages) | ❌ None | ❌ None | -| Notion API fetching | ✅ Excellent (26 tests) | ⚠️ Mocked | ❌ None | -| TMDB API fetching | ✅ Good (13 tests) | ❌ None | ❌ None | -| iTunes API fetching | ✅ Good (10 tests) | ❌ None | ❌ None | -| Cloudinary metadata | ✅ Good (15 tests) | ❌ None | ❌ None | -| Image placeholders | ⚠️ Mocked (5 tests) | ❌ None | ❌ None | -| Caching | ✅ Good (12 tests) | ❌ None | ❌ None | -| Environment validation | ✅ **Complete** (28 tests) | ❌ None | ❌ None | -| Block fetching | ✅ Excellent (26 tests) | ❌ None | ❌ None | -| Error handling/recovery | ⚠️ Partial | ❌ None | ❌ None | - -## Priority Order - -Based on criticality and impact on build reliability: - -1. **CRITICAL**: `generateStaticParams` - Directly affects what builds -2. **HIGH**: Notion block fetching - Core content rendering, completely untested -3. **HIGH**: Page component integration - Verifies the actual build-time data flow -4. **MEDIUM**: Cloudinary image transformation - Used in multiple places -5. **MEDIUM**: Media item fetching - Needed for likes page -6. **MEDIUM**: Image placeholder error handling - Could crash builds -7. **MEDIUM**: Error reporting and recovery - Overall build robustness -8. **LOW**: Environment validation - Catches config issues early -9. **LOW**: Cache behavior edge cases - Already has good coverage - -## Implementation Plan - -Work through the priority list one test at a time: - -- Create comprehensive unit tests for each component -- Mock external dependencies appropriately -- Test both success and error paths -- Verify error messages are helpful -- Ensure tests follow existing patterns in the codebase - -## Progress Log - -### 2025-12-13 - Session 1 - -**Completed:** - -1. ✅ **`generateStaticParams`** - Created `app/(prose)/[slug]/page.test.tsx` - - 6 comprehensive tests covering success and error cases - - Tests verify correct params structure for Next.js - - Tests verify build failures are explicit when data fetching fails - - Tests verify empty post lists are handled gracefully - - All tests passing - -2. ✅ **Environment validation** - Created `lib/env.test.ts` - - 28 comprehensive tests covering all required env vars - - Tests for missing variables (TMDB, Notion, Cloudinary) - - Tests for empty string validation - - Tests for Zod error message structure - - Tests verify multiple missing vars are reported at once - - All tests passing - -**Updated Test Count:** 246 tests total (was 218) - -**Next Priorities:** - -1. Page component integration tests (Server Components) -2. Image placeholder error handling improvements -3. Build-time error reporting enhancements - -### 2025-12-13 - Session 2 - -**Completed:** 3. ✅ **Blog page component** - Created `app/(prose)/blog/page.test.tsx` - -- 8 comprehensive tests for Server Component build-time behavior -- Tests successful data fetching with descending sort -- Tests skipCache query param handling (`nocache=true`) -- Tests empty posts array handling -- Tests component structure and rendering -- Tests error propagation (build failures when data fetch fails) -- All tests passing - -**Updated Test Count:** 254 tests total (was 246) - -### 2025-12-13 - Session 3 - -**Completed:** 4. ✅ **Dynamic route page component** - Extended `app/(prose)/[slug]/page.test.tsx` - -- 8 additional tests for the page component's default export -- Tests successful post fetching with blocks and navigation -- Tests skipCache query param handling -- Tests `notFound()` behavior when post doesn't exist -- Tests error propagation (build failures when getPost fails) -- Total tests in file: 14 (6 generateStaticParams + 8 page component) -- All tests passing - -**Updated Test Count:** 262 tests total (was 254) - -### 2025-12-13 - Session 4 - -**Completed:** 5. ✅ **Likes page component** - Extended `app/likes/page.test.tsx` - -- 10 additional tests for the page component's default export -- Tests parallel fetching of all 5 media types (TV, movies, books, albums, podcasts) -- Tests skipCache query param handling for iTunes media -- Tests empty responses from all APIs -- Tests error propagation when any of the 5 API calls fail during build -- Tests that build fails fast when Promise.all encounters error -- Total tests in file: 16 (6 helper + 10 page component) -- All tests passing - -**Updated Test Count:** 272 tests total (was 262) - -### 2025-12-13 - Session 5 - -**Completed:** 6. ✅ **Zod validation error utilities** - Created `utils/zod.test.ts` - -- 21 comprehensive tests for `formatValidationError` and `logValidationError` -- Tests single field errors (required, type mismatch, email, min length, regex) -- Tests nested field errors with dot notation (objects, arrays, deep nesting) -- Tests multiple field errors with comma separation and order preservation -- Tests empty path errors (root-level validation) -- Tests real-world scenarios (Notion posts, media items) -- Tests logging functionality (console.warn usage, message format, context handling) -- Tests build-time debugging helpfulness (concise messages, no throw behavior) -- All tests passing - -**Updated Test Count:** 303 tests total (was 272) - -**Build-Time Testing Status:** - -- ✅ All critical and high-priority gaps filled -- ✅ Most medium-priority gaps filled (image placeholder error handling, validation utilities) -- ⚠️ Remaining gaps: Cloudinary transformation tests (5 basic tests exist) -- 🎯 Test suite now provides comprehensive build-time validation coverage diff --git a/.claude/specs/cloudinary-caching.md b/.claude/specs/cloudinary-caching.md deleted file mode 100644 index 863f3c9..0000000 --- a/.claude/specs/cloudinary-caching.md +++ /dev/null @@ -1,124 +0,0 @@ -# Cloudinary Rate Limiting Fix: Local Filesystem Cache - -## Problem - -During local development, Next.js 16 is making repeated Cloudinary API calls for the same images, hitting rate limits within 5-10 minutes of normal dev work. The `Image` component calls the `fetchCloudinaryImageMetadata` function, but it doesn't cache its responses. - -## Current Implementation - -- `ui/image.tsx`: calls `lib/cloudinary/fetchCloudinaryImageMetadata.ts` -- `lib/cloudinary/fetchCloudinaryImageMetadata.ts`: Makes API calls via `cloudinary.api.resource()` with no caching -- Each page refresh triggers fresh API calls for all images - -## Solution: Filesystem Cache for Dev Mode - -Implement a filesystem cache that: - -1. Stores Cloudinary API responses in `.local-cache/cloudinary/` directory -2. Keys cache files by public ID (e.g., `{publicId}.json`) -3. Checks cache before making API calls -4. Only enabled in development mode (check `process.env.NODE_ENV === 'development'`) -5. Gracefully handles cache read/write errors (logs warning, continues with API call) - -## Implementation Plan - -### 1. Create Cache Utility (`lib/cache/filesystem.ts`) - -```typescript -/** - * Simple filesystem cache for development mode. - * Stores JSON responses keyed by a cache key. - */ -export async function getCached(key: string, dir: string = '.local-cache'): Promise -export async function setCached(key: string, data: T, dir: string = '.local-cache'): Promise -``` - -Features: - -- Sanitize cache keys (replace `/` with `_`, handle special chars) -- Store in `.local-cache/{dir}/{key}.json` -- Return `null` on cache miss or read errors -- Log cache hits/misses for visibility -- Only operate in development mode - -### 2. Update `fetchCloudinaryImageMetadata` - -Add caching layer: - -```typescript -const cacheKey = publicId.replace(/\//g, '_') -const cached = await getCached(cacheKey, 'cloudinary') -if (cached) { - console.log(`✅ Cache hit for "${publicId}"`) - return cached -} - -// ... existing API call ... - -// Cache the result before returning -await setCached(cacheKey, metadata, 'cloudinary') -``` - -### 3. Update `.gitignore` - -Add `.local-cache/` to prevent committing cached responses. - -### 4. Add Cache Bust Mechanism (Optional) - -Consider adding a way to invalidate cache: - -- npm script: `"cache:clear": "rm -rf .local-cache"` -- Or TTL-based invalidation (check file mtime) - -## Why This Approach? - -1. **Reliable in dev mode**: Filesystem cache survives hot reloads and Next.js dev server restarts -2. **Simple**: No external dependencies, just Node.js `fs` module -3. **Safe**: Errors gracefully fall back to API calls -4. **Visible**: Console logs show cache hits/misses -5. **Fast**: Local disk reads are near-instant vs API calls -6. **Production-safe**: Only runs in development mode - -## Alternative Approaches Considered - -### Option A: Add `'use cache'` to `fetchCloudinaryImageMetadata` - -- **Pro**: Uses Next.js 16 built-in caching -- **Con**: Dev mode often bypasses/invalidates cache during hot reloads -- **Con**: Less control over cache behavior - -### Option B: Use Next.js `unstable_cache` - -- **Pro**: More explicit than `'use cache'` directive -- **Con**: Still subject to Next.js dev mode cache invalidation -- **Con**: API is marked as unstable - -### Option C: Redis or in-memory cache - -- **Pro**: Very fast -- **Con**: Overkill for dev mode, requires additional setup -- **Con**: In-memory cache lost on server restart - -## Files to Modify - -1. **Create**: `lib/cache/filesystem.ts` - New cache utility -2. **Modify**: `lib/cloudinary/fetchCloudinaryImageMetadata.ts` - Add cache layer -3. **Modify**: `.gitignore` - Add `.local-cache/` - -## Success Criteria - -- [ ] Cloudinary API calls only made once per unique image during dev session -- [ ] Cache survives hot reloads and page refreshes -- [ ] No rate limiting errors during normal dev work -- [ ] Cache hits logged to console for visibility -- [ ] Cache directory excluded from git -- [ ] Errors gracefully fall back to API calls -- [ ] Only active in development mode - -## Notes for Implementation Agent - -- Use `fs/promises` for async file operations -- Ensure directory exists before writing (use `mkdir -p` equivalent) -- Sanitize public IDs properly (they can contain `/` and other special chars) -- Keep error handling simple: log and continue -- Consider adding timestamp to cached data for future TTL support diff --git a/.claude/specs/input-validation-pattern.md b/.claude/specs/input-validation-pattern.md deleted file mode 100644 index a183365..0000000 --- a/.claude/specs/input-validation-pattern.md +++ /dev/null @@ -1,197 +0,0 @@ -# Input Validation Pattern - -This document describes the established pattern for validating external inputs using Zod. - -## Principle: Validate at I/O Boundaries - -**All external data should be validated with Zod at the point it enters the system.** - -### What to Validate - -1. **Environment variables** - `lib/env.ts` -2. **External API responses** - TMDB, iTunes, Notion, Cloudinary -3. **Filesystem reads** - Cache files, config files -4. **User inputs** - Query params, form data, route params (when needed) - -### Schema Organization - -**Use inline schemas when:** - -- Schema is used in only one file -- Type is not exported for use elsewhere -- Simple, single-purpose validation - -```typescript -// ✅ Inline schema - used only here -const TmdbApiResultSchema = z.object({ - id: z.number(), - title: z.string(), - // ... -}) -``` - -**Use dedicated schema files when:** - -- Schema/type is shared across multiple files -- Part of a domain with multiple related schemas -- Types need to be exported for UI components - -```typescript -// ✅ Dedicated file - lib/notion/schemas/post.ts -export const PostListItemSchema = z.object({ - id: z.string(), - slug: z.string(), - // ... -}) -``` - -## Patterns - -### 1. External API Validation - -**Dual-layer validation** - validate raw API response, then transform: - -```typescript -// Define schemas -const ApiResponseSchema = z.object({ - raw_field: z.string(), - nested: z.object({ value: z.number() }), -}) - -const ProcessedItemSchema = z.object({ - cleanField: z.string(), - value: z.number(), -}) - -// Validate raw response -const apiResult = ApiResponseSchema.safeParse(rawResponse) -if (!apiResult.success) { - throw new Error(`Invalid API response: ${formatValidationError(apiResult.error)}`) -} - -// Transform and validate again -const item = ProcessedItemSchema.safeParse({ - cleanField: apiResult.data.raw_field, - value: apiResult.data.nested.value, -}) -``` - -### 2. Cache Philosophy - -**Cache is a local dev convenience - trust what was written:** - -```typescript -// Cache reads don't validate - data was validated when fetched from source -const cached = await cache.get('post-123', 'notion') -``` - -**Rationale:** - -- Data is validated when fetched from source (before caching) -- Cache is local dev only (trivial to clear and refetch: `rm -rf .local-cache`) -- Avoiding validation prevents schema duplication for transformed data -- If cache becomes corrupted, clear it and rebuild - -### 3. Error Handling - -Use `safeParse()` for graceful error handling: - -```typescript -const result = MySchema.safeParse(data) -if (!result.success) { - // Log helpful context - console.warn(`Invalid data for ${key}:`, result.error.message) - return null // or throw, depending on criticality -} - -const validData = result.data // TypeScript knows this is valid -``` - -**Validation Error Helpers (`utils/zod.ts`):** - -Use these helpers for cleaner, more consistent error messages: - -```typescript -import { formatValidationError, logValidationError } from '@/utils/logging/zod' - -// For throwing errors - extracts field names and messages -const result = MySchema.safeParse(data) -if (!result.success) { - throw new Error(`Invalid data: ${formatValidationError(result.error)}`) - // Output: "Invalid data: title: Required, date: Invalid format" -} - -// For logging warnings - includes context -const result = MySchema.safeParse(data) -if (!result.success) { - logValidationError(result.error, 'post metadata') - // Output: "Skipping invalid post metadata (title: Required, date: Invalid format)" -} -``` - -### 4. Optional vs Required Fields - -Be explicit about optionality: - -```typescript -const Schema = z.object({ - required: z.string(), // Must exist - optional: z.string().optional(), // May be undefined - nullable: z.string().nullable(), // May be null - nullish: z.string().nullish(), // May be null or undefined -}) -``` - -## Type Safety - -**Never use type assertions for external data:** - -```typescript -// ❌ Bad - no runtime validation -const data = apiResponse as MyType - -// ✅ Good - runtime validation with safeParse -const result = MySchema.safeParse(apiResponse) -if (result.success) { - const data = result.data // Validated -} -``` - -**Exception:** Type assertions are OK in tests and for backward compatibility (cache without schema). - -## Examples in Codebase - -- **Environment**: `lib/env.ts` - Validates all env vars at startup -- **APIs**: `lib/cloudinary/fetchCloudinaryImageMetadata.ts` - Dual-layer validation -- **Cache**: `lib/notion/getPosts.ts` - Cache with schema validation -- **Blocks**: `lib/notion/schemas/block.ts` - Complex recursive validation - -## Adding New External Inputs - -When adding a new external data source: - -1. **Define schema** (inline or dedicated file based on usage) -2. **Validate at boundary** using `safeParse()` -3. **Handle errors** gracefully with context -4. **Add schema to cache reads** if applicable -5. **Write tests** for validation edge cases - -## Testing - -Test both success and failure cases: - -```typescript -describe('validation', () => { - it('accepts valid data', () => { - const result = MySchema.safeParse(validData) - expect(result.success).toBe(true) - }) - - it('rejects invalid data', () => { - const result = MySchema.safeParse(invalidData) - expect(result.success).toBe(false) - }) -}) -``` - -See `lib/cache/filesystem.test.ts` for comprehensive validation testing examples. diff --git a/.claude/specs/rss-feed.md b/.claude/specs/rss-feed.md deleted file mode 100644 index 9986954..0000000 --- a/.claude/specs/rss-feed.md +++ /dev/null @@ -1,158 +0,0 @@ -# RSS Feed Implementation Plan - -## Goal - -Add an RSS feed at `/rss.xml/` with full post content rendered from Notion blocks. - -## Approach - -Use the `feed` npm package + a dedicated HTML renderer for blocks (can't use existing React components because `Code` and `CloudinaryImage` are async Server Components, which `renderToStaticMarkup` doesn't support). - -## Files to Create/Modify - -### 1. Create `io/notion/renderBlocksToHtml.ts` (new) - -Renders Notion blocks to HTML strings for RSS. Simpler than React components: - -- Code blocks: `
` without syntax highlighting
-- Images: Direct `` tags with Cloudinary URLs
-- Text: Handles bold, italic, code, links, strikethrough
-
-```typescript
-import { type GroupedBlock, type RichTextItem } from '@/io/notion/schemas/block'
-
-export function renderBlocksToHtml(blocks: GroupedBlock[]): string {
-  return blocks.map(renderBlock).join('\n')
-}
-
-function renderBlock(block: GroupedBlock): string {
-  switch (block.type) {
-    case 'paragraph':
-      return `

${renderRichText(block.richText)}

` - case 'heading_1': - return `

${renderRichText(block.richText)}

` - case 'heading_2': - return `

${renderRichText(block.richText)}

` - case 'heading_3': - return `

${renderRichText(block.richText)}

` - case 'bulleted_list': - return `
    ${block.items.map(item => `
  • ${renderRichText(item.richText)}
  • `).join('')}
` - case 'numbered_list': - return `
    ${block.items.map(item => `
  1. ${renderRichText(item.richText)}
  2. `).join('')}
` - case 'code': - const code = block.richText.map(item => item.content).join('') - return `
${escapeHtml(code)}
` - case 'quote': - return `
${renderRichText(block.richText)}
` - case 'image': - return `` - case 'video': - return `

[Video: ${block.caption || block.url}]

` - default: - return '' - } -} - -function renderRichText(items: RichTextItem[]): string { - return items - .map(item => { - let text = escapeHtml(item.content) - if (item.code) text = `${text}` - if (item.bold) text = `${text}` - if (item.italic) text = `${text}` - if (item.strikethrough) text = `${text}` - if (item.underline) text = `${text}` - if (item.link) text = `${text}` - return text - }) - .join('') -} - -function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} -``` - -### 2. Create `app/rss.xml/route.ts` (new) - -```typescript -import { Feed } from 'feed' -import { getPosts } from '@/io/notion/getPosts' -import getPost from '@/io/notion/getPost' -import { renderBlocksToHtml } from '@/io/notion/renderBlocksToHtml' - -const SITE_URL = 'https://michaeluloth.com' - -export async function GET() { - const posts = (await getPosts({ sortDirection: 'descending' })).unwrap() - - const feed = new Feed({ - title: 'Michael Uloth', - description: 'Software engineer helping scientists discover new medicines at Recursion.', - id: SITE_URL, - link: SITE_URL, - language: 'en', - favicon: `${SITE_URL}/favicon.ico`, - copyright: `All rights reserved ${new Date().getFullYear()}, Michael Uloth`, - author: { - name: 'Michael Uloth', - link: SITE_URL, - }, - }) - - // Fetch full content for each post - for (const postItem of posts) { - const post = (await getPost({ slug: postItem.slug, includeBlocks: true })).unwrap() - if (!post) continue - - feed.addItem({ - title: post.title, - id: `${SITE_URL}/${post.slug}/`, - link: `${SITE_URL}/${post.slug}/`, - description: post.description ?? undefined, - content: renderBlocksToHtml(post.blocks), - date: new Date(post.firstPublished), - author: [{ name: 'Michael Uloth', link: SITE_URL }], - }) - } - - return new Response(feed.rss2(), { - headers: { - 'Content-Type': 'application/xml', - 'Cache-Control': 'public, max-age=3600', - }, - }) -} -``` - -### 3. Install dependency - -```bash -npm install feed -``` - -## Key Files - -| File | Purpose | -| --------------------------------- | ------------------------------------------ | -| `app/rss.xml/route.ts` | Route handler serving RSS feed | -| `io/notion/renderBlocksToHtml.ts` | Converts Notion blocks to HTML strings | -| `io/notion/getPosts.ts` | Fetches post list (existing) | -| `io/notion/getPost.ts` | Fetches single post with blocks (existing) | -| `io/notion/schemas/block.ts` | Block types (existing) | - -## Design Decisions - -1. **Separate HTML renderer** vs reusing React components: Required because `Code` and `CloudinaryImage` are async Server Components that `renderToStaticMarkup` doesn't support. - -2. **No syntax highlighting in RSS**: Code blocks render as plain `
`. RSS readers have inconsistent styling support anyway.
-
-3. **Direct Cloudinary URLs**: Images use the raw URL without fetching metadata. Simpler and faster.
-
-4. **`feed` library**: Handles XML escaping, CDATA wrapping, and proper RSS 2.0 structure.
-
-## Testing
-
-1. `curl http://localhost:3000/rss.xml/` - verify XML output
-2. Validate at https://validator.w3.org/feed/
-3. Test in RSS reader (Feedly, NetNewsWire, etc.)
diff --git a/.claude/specs/zod-notion-validation.md b/.claude/specs/zod-notion-validation.md
deleted file mode 100644
index 95036e5..0000000
--- a/.claude/specs/zod-notion-validation.md
+++ /dev/null
@@ -1,142 +0,0 @@
-# Zod Validation for Notion Data Fetching
-
-## Context
-
-The `/likes` page (PR #6) established a pattern for parsing external data at I/O boundaries using Zod:
-
-- Parse raw API responses with Zod schemas
-- Transform to convenient internal domain objects
-- Infer TypeScript types from schemas (single source of truth)
-- Handle validation errors gracefully
-- Separate pure transformation functions from I/O
-
-This pattern is already applied to:
-
-- ✅ `lib/notion/getMediaItems.ts` - Media items (books/albums/podcasts)
-- ✅ `lib/tmdb/fetchTmdbList.ts` - TMDB TV shows and movies
-- ✅ `lib/itunes/fetchItunesItems.ts` - iTunes metadata enrichment
-
-## Goal
-
-Apply this pattern to **all remaining Notion data fetching** to:
-
-1. Make no assumptions about data from the Notion SDK
-2. Parse and validate at the API boundary
-3. Output type-safe internal domain objects with ergonomic, flat APIs
-
-## Design Principles
-
-- **Domain-first naming**: Use `Post`, `PostListItem` — not `NotionPost`
-- **Ergonomic APIs**: Flatten nested Notion structures at the boundary
-  - Before: `getPropertyValue(post.properties, 'Title')`
-  - After: `post.title`
-- **Validation over reshaping for blocks**: Blocks are complex; validate structure but keep Notion shape for now
-
-## Scope
-
-### In Scope
-
-1. **`lib/notion/getPosts.ts`** (Priority 1)
-   - Currently: Returns `any[]`, callsites use `getPropertyValue()` repeatedly
-   - Output: `PostListItem[]` with flat, ergonomic API
-   - Callsites: `app/(prose)/blog/page.tsx`, `app/(prose)/[slug]/page.tsx`, `getPost.ts`
-
-2. **`lib/notion/getPost.ts`** (Priority 2)
-   - Currently: Returns `any` with `prevPost`/`nextPost`
-   - Output: `Post | null` with flat metadata + validated blocks
-   - Callsites: `app/(prose)/[slug]/page.tsx`
-
-3. **`lib/notion/getBlockChildren.ts`** (Priority 3)
-   - Currently: Returns raw blocks from Notion SDK
-   - Output: Validated `Block[]` (keep Notion shape, just validate structure)
-   - Note: Block types already defined in `lib/notion/types.ts` using SDK types
-
-### Out of Scope (for now)
-
-- `lib/notion/getPropertyValue.ts` - Will become internal to transform functions, not used by callsites
-- `lib/notion/getPage.ts` - Not currently used in app
-- `lib/cloudinary/fetchCloudinaryImageMetadata.ts` - Different API, separate task
-- Block reshaping - Validate structure only; keep Notion shape for renderers
-
-## Implementation Plan
-
-### Step 1: Create `lib/notion/schemas/post.ts`
-
-Define domain types with flat, ergonomic APIs:
-
-```typescript
-import { z } from 'zod'
-
-export const PostListItemSchema = z.object({
-  id: z.string().min(1),
-  slug: z.string().min(1),
-  title: z.string().min(1),
-  description: z.string().optional(),
-  firstPublished: z.string().regex(/^\d{4}-\d{2}-\d{2}/),
-  featuredImage: z.url().optional(),
-})
-
-export type PostListItem = z.infer
-
-export const PostSchema = PostListItemSchema.extend({
-  lastEditedTime: z.string(),
-  blocks: z.array(z.unknown()), // Validated separately
-  prevPost: PostListItemSchema.nullable(),
-  nextPost: PostListItemSchema.nullable(),
-})
-
-export type Post = z.infer
-```
-
-### Step 2: Refactor `getPosts.ts`
-
-1. Add `transformNotionPagesToPostListItems()` pure function
-2. Use `getPropertyValue` internally to extract, then validate with schema
-3. Return `PostListItem[]` instead of `any[]`
-4. Follow pattern from `getMediaItems.ts`
-
-### Step 3: Update `getPosts` callsites
-
-- `app/(prose)/blog/page.tsx` - Remove `getPropertyValue` calls, use `post.title` etc.
-- `app/(prose)/[slug]/page.tsx` (`generateStaticParams`) - Use `post.slug` directly
-
-### Step 4: Refactor `getPost.ts`
-
-1. Add `transformNotionPageToPost()` pure function
-2. Reuse `PostListItem` for `prevPost`/`nextPost`
-3. Return `Post | null` instead of `any`
-
-### Step 5: Update `getPost` callsites
-
-- `app/(prose)/[slug]/page.tsx` - Pass typed `Post` to components
-- `app/(prose)/[slug]/ui/post.tsx` - Change props from `any` to `Post`
-
-### Step 6: Validate blocks in `getBlockChildren.ts`
-
-- Minimal validation: ensure array of objects with `id` and `type`
-- Keep Notion shape for renderers
-- Type as `Block[]` using existing SDK types from `types.ts`
-
-## Files to Modify
-
-| File                             | Change                                 |
-| -------------------------------- | -------------------------------------- |
-| `lib/notion/schemas/post.ts`     | **New** - Domain schemas               |
-| `lib/notion/getPosts.ts`         | Add transform, return `PostListItem[]` |
-| `lib/notion/getPost.ts`          | Add transform, return `Post \| null`   |
-| `lib/notion/getBlockChildren.ts` | Add minimal validation                 |
-| `app/(prose)/blog/page.tsx`      | Use flat `post.title` API              |
-| `app/(prose)/[slug]/page.tsx`    | Use typed `Post`                       |
-| `app/(prose)/[slug]/ui/post.tsx` | Type props as `Post`                   |
-
-## Testing
-
-Add tests for transform functions (follow `getMediaItems.test.ts` pattern):
-
-- Valid Notion page → correct domain object
-- Missing required field → filtered out with log
-- Optional fields → handled correctly
-
-## Next Priority
-
-After Notion: `lib/cloudinary/fetchCloudinaryImageMetadata.ts` (has TODO comment)
diff --git a/@AGENTS.md b/@AGENTS.md
deleted file mode 100644
index 05ac53b..0000000
--- a/@AGENTS.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Agent Instructions
-
-This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
-
-## Quick Reference
-
-```bash
-bd ready              # Find available work
-bd show           # View issue details
-bd update  --status in_progress  # Claim work
-bd close          # Complete work
-bd sync               # Sync with git
-```
-
-## Landing the Plane (Session Completion)
-
-**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
-
-**MANDATORY WORKFLOW:**
-
-1. **File issues for remaining work** - Create issues for anything that needs follow-up
-2. **Run quality gates** (if code changed) - Tests, linters, builds
-3. **Update issue status** - Close finished work, update in-progress items
-4. **PUSH TO REMOTE** - This is MANDATORY:
-   ```bash
-   git pull --rebase
-   bd sync
-   git push
-   git status  # MUST show "up to date with origin"
-   ```
-5. **Clean up** - Clear stashes, prune remote branches
-6. **Verify** - All changes committed AND pushed
-7. **Hand off** - Provide context for next session
-
-**CRITICAL RULES:**
-
-- Work is NOT complete until `git push` succeeds
-- NEVER stop before pushing - that leaves work stranded locally
-- NEVER say "ready to push when you are" - YOU must push
-- If push fails, resolve and retry until it succeeds
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 05ac53b..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Agent Instructions
-
-This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
-
-## Quick Reference
-
-```bash
-bd ready              # Find available work
-bd show           # View issue details
-bd update  --status in_progress  # Claim work
-bd close          # Complete work
-bd sync               # Sync with git
-```
-
-## Landing the Plane (Session Completion)
-
-**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
-
-**MANDATORY WORKFLOW:**
-
-1. **File issues for remaining work** - Create issues for anything that needs follow-up
-2. **Run quality gates** (if code changed) - Tests, linters, builds
-3. **Update issue status** - Close finished work, update in-progress items
-4. **PUSH TO REMOTE** - This is MANDATORY:
-   ```bash
-   git pull --rebase
-   bd sync
-   git push
-   git status  # MUST show "up to date with origin"
-   ```
-5. **Clean up** - Clear stashes, prune remote branches
-6. **Verify** - All changes committed AND pushed
-7. **Hand off** - Provide context for next session
-
-**CRITICAL RULES:**
-
-- Work is NOT complete until `git push` succeeds
-- NEVER stop before pushing - that leaves work stranded locally
-- NEVER say "ready to push when you are" - YOU must push
-- If push fails, resolve and retry until it succeeds
diff --git a/app/(prose)/[slug]/ui/post.tsx b/app/(prose)/[slug]/ui/post.tsx
index 19600ef..1d9ad99 100644
--- a/app/(prose)/[slug]/ui/post.tsx
+++ b/app/(prose)/[slug]/ui/post.tsx
@@ -13,13 +13,16 @@ type Props = Readonly<{
 
 export default function Post({ post, prevPost, nextPost }: Props) {
   return (
-    
-
- - - {/* needs to be a client component */} - -
-
+ <> +
+
+ + + {/* needs to be a client component */} +
+
+ {/* Pagination is site navigation, not post content, so it lives outside
*/} + + ) } diff --git a/app/(prose)/blog/page.test.tsx b/app/(prose)/blog/page.test.tsx index ac0d023..21596e0 100644 --- a/app/(prose)/blog/page.test.tsx +++ b/app/(prose)/blog/page.test.tsx @@ -77,9 +77,10 @@ describe('Blog page', () => { const jsx = await Blog() render(jsx) - // Verify main element exists with correct class + // Verify main element exists with correct class and id for skip link const main = screen.getByRole('main') expect(main).toHaveClass('flex-auto') + expect(main).toHaveAttribute('id', 'main') // Verify heading exists (sr-only) expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Blog') diff --git a/app/(prose)/blog/page.tsx b/app/(prose)/blog/page.tsx index ec7578f..02d335b 100644 --- a/app/(prose)/blog/page.tsx +++ b/app/(prose)/blog/page.tsx @@ -25,7 +25,7 @@ export const metadata: Metadata = { export default async function Blog(): Promise { return ( -
+

Blog

diff --git a/app/(prose)/layout.test.tsx b/app/(prose)/layout.test.tsx new file mode 100644 index 0000000..b7ccd61 --- /dev/null +++ b/app/(prose)/layout.test.tsx @@ -0,0 +1,54 @@ +/** + * @vitest-environment happy-dom + */ + +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import ProseLayout from './layout' + +// Mock the child components +vi.mock('@/ui/header', () => ({ + default: () =>
Header
, +})) + +vi.mock('@/ui/footer', () => ({ + default: () =>
Footer
, +})) + +describe('ProseLayout', () => { + describe('skip link', () => { + it('renders skip link with correct href and text', () => { + render(Test content) + + const skipLink = screen.getByRole('link', { name: /skip to main content/i }) + expect(skipLink).toBeInTheDocument() + expect(skipLink).toHaveAttribute('href', '#main') + }) + + it('has sr-only class for screen reader only visibility', () => { + render(Test content) + + const skipLink = screen.getByRole('link', { name: /skip to main content/i }) + expect(skipLink).toHaveClass('sr-only') + }) + + it('renders skip link as first element for keyboard navigation', () => { + const { container } = render(Test content) + + // Get the first anchor element in the container + const firstLink = container.querySelector('a') + expect(firstLink).toHaveTextContent('Skip to main content') + }) + }) + + it('renders children', () => { + render(Test child content) + expect(screen.getByText('Test child content')).toBeInTheDocument() + }) + + it('renders header and footer', () => { + render(Content) + expect(screen.getByText('Header')).toBeInTheDocument() + expect(screen.getByText('Footer')).toBeInTheDocument() + }) +}) diff --git a/app/(prose)/layout.tsx b/app/(prose)/layout.tsx index e0e5ef1..4be218e 100644 --- a/app/(prose)/layout.tsx +++ b/app/(prose)/layout.tsx @@ -9,6 +9,12 @@ type Props = Readonly<{ export default function ProseLayout({ children }: Props) { return (
+ + Skip to main content +
{children}