From ab0251794a5d6814b77b277da37a1645302bd9b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?j=C3=A9r=C3=B4me=20boileux?= Date: Fri, 22 Aug 2025 17:12:13 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=9D=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_ROUTES_REFACTORING_GUIDE.md | 1017 +++++++++++++++++++++++++++++++ 1 file changed, 1017 insertions(+) create mode 100644 API_ROUTES_REFACTORING_GUIDE.md diff --git a/API_ROUTES_REFACTORING_GUIDE.md b/API_ROUTES_REFACTORING_GUIDE.md new file mode 100644 index 0000000..db6b9b1 --- /dev/null +++ b/API_ROUTES_REFACTORING_GUIDE.md @@ -0,0 +1,1017 @@ +# API Routes Refactoring Guide for Claude Code Agent + +## ๐ŸŽฏ **Project Context** + +This is a React Router v7 application deployed on Vercel with a **framework-native cache strategy** already successfully implemented. We've established a working API route pattern and need to systematically expand it. + +## โœ… **Current State (Already Implemented)** + +### **Working Implementation:** +- **Framework-Native Cache Control**: Headers applied in `entry.server.tsx` with `Vercel-CDN-Cache-Control` (highest priority) +- **Cache Strategy Module**: `app/modules/cache.ts` with `getCacheHeaders()`, `getCacheStrategyForPath()`, and `shouldBypassCache()` +- **First API Route**: `app/routes/api.blog._index.tsx` returning JSON blog posts with proper cache headers +- **Content API**: `app/modules/content/api.ts` with `fetchBlogposts()`, `fetchStories()`, etc. + +### **Verified Working:** +```bash +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/blog +# Returns: cache-control: s-maxage=3600, stale-while-revalidate=86400 (current) +# After optimization: s-maxage=604800, stale-while-revalidate=1209600 (7d + 14d) +# Note: This will be renamed to /api/posts in Phase 1 +``` + +## ๐Ÿš€ **Refactoring Goals** + +Create a complete API-first architecture with these endpoints: + +### **Phase 1: Core Content APIs** +```typescript +GET /api/posts // โœ… DONE - Blog posts listing (renamed from /api/blog) +GET /api/posts/:slug // Individual blog post +GET /api/stories // Client stories listing +GET /api/stories/:slug // Individual story +``` + +### **Phase 2: Enhanced APIs** +```typescript +GET /api/search // Search across content +GET /api/tags // Available tags +GET /api/sitemap // Site structure +``` + +### **Phase 2.5: Cache Strategy Cleanup** +Simplify cache module and remove unnecessary complexity: +```typescript +// Remove createHybridLoader entirely +// Separate API vs HTML caching strategies +// Reduce cache.ts from 129 โ†’ ~60 lines +``` + +### **Phase 3: Pages Refactoring** +Refactor HTML pages to use API routes instead of direct backend calls: +```typescript +// Current: Direct backend calls +_main.blog._index.tsx โ†’ fetchBlogposts() + +// Target: API route calls +_main.blog._index.tsx โ†’ fetch('/api/posts') +``` + +## ๐Ÿ“‹ **Implementation Pattern (Follow This Exactly)** + +### **File Structure:** +``` +app/routes/ +โ”œโ”€โ”€ api.posts._index.tsx // โœ… DONE - Blog posts listing (rename from api.blog._index.tsx) +โ”œโ”€โ”€ api.posts.$slug.tsx // Individual blog post +โ”œโ”€โ”€ api.stories._index.tsx // Stories listing +โ”œโ”€โ”€ api.stories.$slug.tsx // Individual story +โ”œโ”€โ”€ api.search.tsx // Search endpoint +โ””โ”€โ”€ api.tags.tsx // Tags endpoint +``` + +### **Required Route Template:** +Use this exact pattern for all new API routes: + +```typescript +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchContent } from '~/modules/content'; // Import appropriate function + +export async function loader({ request, params }: LoaderFunctionArgs) { + const url = new URL(request.url); + + // Extract parameters (adjust per route) + const lang = url.searchParams.get('lang') || 'fr'; + const slug = params?.slug; + + // Fetch data using existing content API + const [status, state, data] = await fetchContent(/* parameters */); + + // Handle errors + if (status !== 200 || !data) { + console.error(`Failed to fetch content: ${state}`); + return new Response( + JSON.stringify({ data: null, isError: true, error: state }), + { + status: status === 404 ? 404 : 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders('blogListing', shouldBypassCache(request)); // Use 'blogListing' for lists, 'blogPost' for individual posts + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Transform/filter data as needed + const transformedData = data; // Apply any transformations + + // Return success response with cache headers + const cacheHeaders = getApiCacheHeaders('blogListing', shouldBypassCache(request)); // Use 'blogListing' for lists, 'blogPost' for individual posts + + return new Response( + JSON.stringify({ data: transformedData, isError: false, total: transformedData.length }), + { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); +} +``` + +## ๐Ÿ”„ **Critical Architectural Change: Pages โ†’ API Routes** + +### **Why This Change Is Essential** + +Currently, HTML pages call backend methods directly: +```typescript +// โŒ CURRENT: Direct backend calls (creates dual data paths) +export const loader = createHybridLoader(async ({ request }) => { + const [status, state, blogData] = await fetchBlogposts(lang); // Direct backend + return { posts: blogData }; +}); +``` + +This creates **two separate data paths**: +- HTML pages โ†’ `fetchBlogposts()` +- External consumers โ†’ `/api/blog` + +**We need ONE unified data path**: HTML pages + External consumers โ†’ `/api/blog` + +### **Page Refactoring Pattern** + +Use this exact pattern to refactor all HTML pages: + +```typescript +// โœ… NEW: API route calls (unified data path) +export const loader = createHybridLoader( + async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + + // Extract existing parameters + const tag = url.searchParams.get('tag'); + const lang = url.searchParams.get('lang') || 'fr'; + + // Build API URL with same origin (internal call) + const apiUrl = new URL('/api/posts', url.origin); + if (tag) apiUrl.searchParams.set('tag', tag); + if (lang !== 'fr') apiUrl.searchParams.set('lang', lang); + + try { + // Call internal API route (optimised on Vercel) + const response = await fetch(apiUrl.toString()); + + if (!response.ok) { + console.error(`API call failed: ${response.status}`); + return { posts: [], isError: true }; + } + + const { data, isError, total } = await response.json(); + + if (isError) { + console.error('API returned error'); + return { posts: [], isError: true }; + } + + return { posts: data || [], isError: false, total }; + + } catch (error) { + console.error('Failed to fetch from API:', error); + return { posts: [], isError: true }; + } + }, + 'blogPost', // Keep same cache strategy for HTML pages +); +``` + +### **Pages to Refactor (Phase 3)** + +Implement in this order after completing API routes: + +1. **`_main.blog._index.tsx`** โ†’ Use `/api/posts` +2. **`_main.blog.$slug.tsx`** โ†’ Use `/api/posts/:slug` +3. **`_main.clients._index.tsx`** โ†’ Use `/api/stories` +4. **`_main.clients.$slug.tsx`** โ†’ Use `/api/stories/:slug` + +### **Key Benefits of This Architecture** + +#### **๐ŸŽฏ Single Source of Truth** +```typescript +// โœ… ONE data pipeline +GitHub API โ†’ /api/blog โ†’ { HTML pages + External consumers } + โ†‘ + Cache headers applied once +``` + +#### **๐Ÿ”ง Simplified Maintenance** +- Data transformations happen **once** in API routes +- Logic changes affect both HTML and API consumers automatically +- Consistent response format everywhere +- Easier testing and debugging + +#### **โšก Performance Considerations** +- **Vercel optimisation**: Internal API calls are function-to-function (not HTTP) +- Same serverless context, minimal overhead +- Cache headers preserved correctly +- **Negligible performance cost** with significant architectural benefits + +### **Testing Page Refactoring** + +For each refactored page: + +```bash +# Test page still loads correctly +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/blog + +# Test page with parameters +curl -I "https://ocobo-git-api-routes-wabdsgn.vercel.app/blog?tag=revenue" + +# Test API endpoint directly +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts + +# Verify no duplicate API calls in browser dev tools +``` + +### **Important Notes for Page Refactoring** + +#### **DO:** +- Remove direct imports of `fetchBlogposts`, `fetchStories` from pages +- Use same cache strategy names (`'blogPost'`, `'story'`) for HTML pages +- Preserve all existing URL parameters and pass them to API routes +- Handle API errors gracefully with fallback empty arrays +- Test both local and deployed versions + +#### **DON'T:** +- Change the cache strategy logic in `cache.ts` +- Modify the API route response format after implementing +- Remove error handling or change the loader return format +- Break existing URL parameter behaviour + +## ๐Ÿ”ง **Technical Requirements** + +### **โšก PERFORMANCE BOOST: Optimized Cache Durations** + +**Balanced strategy optimized for your publishing frequency**: + +| Content Type | Cache Strategy | Purpose | Timing | +|-------------|---------------|---------|--------| +| **Blog Listings** | `1h + 2h` | **New content visibility** | Max 3h delay | +| **Individual Posts** | `7d + 14d` | **Performance** | 21x improvement | +| **Story Listings** | `1h + 2h` | **New content visibility** | Max 3h delay | +| **Individual Stories** | `14d + 30d` | **Performance** | 42x improvement | +| **Static Content** | `30d + 60d` | **Maximum efficiency** | 11x improvement | + +**Result**: **Best of both worlds** - fast new content discovery + aggressive performance optimization! + +### **๐Ÿ”„ CRITICAL: New Content Visibility Strategy** + +**Problem**: Aggressive caching could hide new articles for days! + +**Solution**: **Differentiated Cache Strategy** +- **Listing pages** (short cache): Users see new content within 1-3 hours +- **Individual content** (long cache): Maximum performance for unchanged content + +### **Cache Strategy Selection:** +```typescript +// Use these cache strategies based on content type: +'blogListing' // For /api/posts listing (1h + 2h) - show new articles quickly +'blogPost' // For /api/posts/:slug (7d + 14d) - individual posts don't change +'storyListing' // For /api/stories listing (1h + 2h) - show new stories quickly +'story' // For /api/stories/:slug (14d + 30d) - individual stories don't change +'static' // For tags, search, sitemap (30d + 60d) - rarely changes +``` + +### **Alternative Solutions for Immediate Visibility:** + +#### **Option 1: Cache Purging (Advanced)** +```typescript +// Webhook after GitHub content push +await purgeCache([ + '/api/posts', // Clear listing cache + '/blog', // Clear HTML page cache + '/api/posts/new-slug' // Clear specific post if updating +]); +``` + +#### **Option 2: Manual Refresh (Simple)** +When publishing new content, admin can: +```bash +# Force refresh of listing pages +curl "https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts?refresh=1" +curl "https://ocobo-git-api-routes-wabdsgn.vercel.app/blog?refresh=1" +``` + +#### **Option 3: Time-Based Strategy (Balanced)** +```typescript +// Shorter cache during "publishing hours" (e.g., weekdays 9-17) +const isPublishingTime = () => { + const now = new Date(); + const hour = now.getHours(); + const day = now.getDay(); + return day >= 1 && day <= 5 && hour >= 9 && hour <= 17; // Mon-Fri 9am-5pm +}; + +const maxAge = isPublishingTime() ? 1800 : 604800; // 30min vs 7days +``` + +### **Response Format (Standardise):** +```typescript +// Success Response +{ + data: T, // The actual content + isError: false, + total?: number // For listings +} + +// Error Response +{ + data: null, + isError: true, + error: string // Error message +} +``` + +### **URL Parameters Support:** +All routes should support these query parameters: +- `lang=fr|en` - Language selection +- `refresh=1` - Cache bypass (handled by `shouldBypassCache()`) +- Route-specific parameters as needed + +## ๐Ÿงน **Phase 2.5: Cache Strategy Cleanup** + +**Execute this phase after completing all API routes but before refactoring pages.** + +### **Why Cleanup Is Essential** + +Current `cache.ts` has become over-engineered for the new API-first architecture: +- `createHybridLoader` is now just a wrapper doing nothing useful +- Mixed concerns (API caching + HTML caching) +- 129 lines of complexity for simple functionality +- Unused strategy parameter + +### **Cleanup Goals** +- **Reduce file complexity**: 129 โ†’ ~60 lines (-53%) +- **Separate concerns**: API caching vs HTML caching +- **Remove dead code**: `createHybridLoader` and related functions +- **Simplify page loaders**: Standard React Router patterns + +### **Step 1: Simplify Cache Module** + +Replace the complex `cache.ts` with this simplified version: + +```typescript +// app/modules/cache.ts - SIMPLIFIED VERSION +/** + * Simplified cache strategy for API-first architecture + */ +import { getPrivateEnvVars } from './env.server'; + +export type CacheStrategy = 'blogListing' | 'blogPost' | 'storyListing' | 'story' | 'static'; + +// BALANCED: Aggressive cache for individual content, shorter for listings to show new content +const CACHE_CONFIG = { + // Blog post listings: Short cache to show new articles quickly + blogListing: { maxAge: 3600, staleWhileRevalidate: 7200 }, // 1h + 2h + // Individual blog posts: Long cache since content doesn't change once published + blogPost: { maxAge: 604800, staleWhileRevalidate: 1209600 }, // 7d + 14d + + // Story listings: Short cache to show new stories quickly + storyListing: { maxAge: 3600, staleWhileRevalidate: 7200 }, // 1h + 2h + // Individual stories: Long cache since content doesn't change once published + story: { maxAge: 1209600, staleWhileRevalidate: 2592000 }, // 14d + 30d + + // Static content: cache for 30 days + 60 days stale = 90 days total + static: { maxAge: 2592000, staleWhileRevalidate: 5184000 }, // 30d + 60d +} as const; + +function isUsingGitHub(): boolean { + try { + const { readContentFrom } = getPrivateEnvVars(); + return readContentFrom === 'github'; + } catch { + return false; + } +} + +export function shouldBypassCache(request: Request): boolean { + const url = new URL(request.url); + return url.searchParams.has('refresh'); +} + +function buildCacheControl(strategy: CacheStrategy): string { + const { maxAge, staleWhileRevalidate } = CACHE_CONFIG[strategy]; + return `s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`; +} + +/** + * Cache headers for API routes (data caching) + */ +export function getApiCacheHeaders(strategy: CacheStrategy, bypassCache = false) { + if (bypassCache) { + return { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Vercel-CDN-Cache-Control': 'no-cache, no-store, must-revalidate', + }; + } + + if (isUsingGitHub()) { + const cacheControl = buildCacheControl(strategy); + return { + 'Cache-Control': cacheControl, + 'Vercel-CDN-Cache-Control': cacheControl, + Vary: 'Accept-Language', + }; + } + + return { + 'Cache-Control': 'no-cache', + 'Vercel-CDN-Cache-Control': 'no-cache', + }; +} + +/** + * Cache headers for HTML pages (rendering caching) + * Shorter cache for faster content updates + */ +export function getHtmlCacheHeaders(bypassCache = false) { + if (bypassCache) { + return { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Vercel-CDN-Cache-Control': 'no-cache, no-store, must-revalidate', + }; + } + + if (isUsingGitHub()) { + return { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=3600', // 5min + 1h + 'Vercel-CDN-Cache-Control': 's-maxage=300, stale-while-revalidate=3600', + Vary: 'Accept-Language', + }; + } + + return { + 'Cache-Control': 'no-cache', + 'Vercel-CDN-Cache-Control': 'no-cache', + }; +} + +// Remove these functions entirely: +// - createHybridLoader +// - getCacheHeaders (replaced by getApiCacheHeaders/getHtmlCacheHeaders) +// - getCacheStrategyForPath (pages use unified HTML caching) +// - logCacheStrategy (optional utility) +``` + +### **Step 2: Update API Routes** + +Update existing API route to use new function name: + +```typescript +// app/routes/api.posts._index.tsx (rename from api.blog._index.tsx) +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; + +export async function loader({ request }: LoaderFunctionArgs) { + // ... existing logic ... + + // UPDATED: Use 'blogListing' for listing endpoint (short cache for new content visibility) + const cacheHeaders = getApiCacheHeaders('blogListing', shouldBypassCache(request)); + + return new Response(JSON.stringify({ data, isError: false, total }), { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }); +} + +// For individual post endpoints, use 'blogPost' strategy: +// const cacheHeaders = getApiCacheHeaders('blogPost', shouldBypassCache(request)); +``` + +### **Step 3: Update entry.server.tsx** + +Simplify cache header application in `entry.server.tsx`: + +```typescript +// app/entry.server.tsx +import { getHtmlCacheHeaders, shouldBypassCache } from '~/modules/cache'; + +export default async function handleRequest(/* ... */) { + // ... existing code ... + + return new Promise((resolve, reject) => { + // ... renderToPipeableStream setup ... + + [callbackName]: () => { + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + responseHeaders.set('Content-Type', 'text/html'); + + // SIMPLIFIED: All HTML pages use same cache strategy + const cacheHeaders = getHtmlCacheHeaders(shouldBypassCache(request)); + + // Apply cache headers + for (const [key, value] of Object.entries(cacheHeaders)) { + responseHeaders.set(key, value); + } + + resolve(new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + })); + pipe(body); + }, + // ... rest unchanged ... + }); +} +``` + +### **Step 4: Test Cache Cleanup** + +Verify the cleanup works correctly: + +```bash +# Test API listing endpoints (short cache for new content visibility) +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts +# Should show: cache-control: s-maxage=3600, stale-while-revalidate=7200 (1h + 2h) + +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/stories +# Should show: cache-control: s-maxage=3600, stale-while-revalidate=7200 (1h + 2h) + +# Test individual content endpoints (long cache for performance) +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts/some-slug +# Should show: cache-control: s-maxage=604800, stale-while-revalidate=1209600 (7d + 14d) + +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/stories/some-slug +# Should show: cache-control: s-maxage=1209600, stale-while-revalidate=2592000 (14d + 30d) + +# Test HTML pages have lighter cache headers +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/blog +# Should show: cache-control: s-maxage=300, stale-while-revalidate=3600 (5min + 1h) +``` + +### **Cache Cleanup Benefits** + +#### **Complexity Reduction:** +- **cache.ts**: 129 โ†’ ~60 lines (-53% complexity) +- **Separation of concerns**: API data caching vs HTML rendering caching +- **Cleaner imports**: Clear function names for specific use cases + +#### **Performance Optimisation:** +- **Blog listings**: `1h + 2h` = **New articles visible within 3 hours maximum** +- **Individual posts**: `7d + 14d` = **21x performance improvement** (unchanged content) +- **Story listings**: `1h + 2h` = **New stories visible within 3 hours maximum** +- **Individual stories**: `14d + 30d` = **42x performance improvement** (unchanged content) +- **Static content**: `30d + 60d` = **Ultimate efficiency** for rarely-changed content +- **Result**: **Best of both worlds** - fast new content visibility + aggressive performance + +#### **Maintainability:** +- **Single responsibility**: Each function has clear purpose +- **No dead code**: Removed unused `createHybridLoader` +- **Future-ready**: Clean foundation for additional cache strategies + +**Complete Phase 2.5 before proceeding to Phase 3 page refactoring.** + +### **๐Ÿ“ Publishing Workflow: How Users See New Content** + +**When you publish a new blog post or story:** + +#### **Timeline for New Content Visibility:** +``` +Time 0: New content pushed to GitHub +Time 0-1h: Cache still serves old listing (users don't see new content yet) +Time 1h: Cache expires, next request fetches fresh data from GitHub +Time 1h+: All users see new content immediately +Max delay: 3 hours (1h cache + 2h stale-while-revalidate) +``` + +#### **Real-World Example:** +``` +Monday 10:00 AM: You publish "New Revenue Ops Strategy" blog post +Monday 11:00 AM: Cache expires, users start seeing the new post +Monday 11:05 AM: All global users see the new post on blog listing page +``` + +#### **For Immediate Visibility (Zero Wait):** +```bash +# Admin runs after publishing (takes 2 seconds) +curl "https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts?refresh=1" +curl "https://ocobo-git-api-routes-wabdsgn.vercel.app/blog?refresh=1" +# Result: New content visible instantly for all users +``` + +#### **Why This Works:** +- **Listing pages**: Short cache (1-3h) ensures new content appears quickly +- **Individual posts**: Long cache (7-44d) since content never changes once published +- **Performance maintained**: 95%+ of requests still hit cache (old content) +- **Publishing flexibility**: Manual refresh available for urgent announcements + +## ๐Ÿ“š **Available Content Functions** + +Located in `app/modules/content/api.ts`: + +```typescript +// Use these existing functions: +fetchBlogposts(lang: string): Promise<[number, string, BlogPost[] | null]> +fetchBlogpost(slug: string, lang: string): Promise<[number, string, BlogPost | null]> +fetchStories(lang: string): Promise<[number, string, Story[] | null]> +fetchStory(slug: string, lang: string): Promise<[number, string, Story | null]> + +// Return format: [statusCode, statusMessage, data] +// statusCode: 200 = success, 404 = not found, 500 = error +``` + +## ๐Ÿงช **Testing Protocol** + +For each implemented route, test: + +### **Local Testing:** +```bash +# Test basic functionality +curl -I http://localhost:5173/api/posts +curl -I http://localhost:5173/api/stories + +# Test with parameters +curl -I "http://localhost:5173/api/posts?lang=en&refresh=1" +curl -I "http://localhost:5173/api/stories?tag=saas" +``` + +### **Deployment Testing:** +```bash +# Test cache headers +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/stories + +# Verify JSON response +curl -s https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts | head -20 +curl -s https://ocobo-git-api-routes-wabdsgn.vercel.app/api/stories | head -20 +``` + +### **Expected Headers:** + +**For Listing Endpoints** (`/api/posts`, `/api/stories`): +``` +cache-control: s-maxage=3600, stale-while-revalidate=7200 +content-type: application/json +vary: Accept-Language +``` + +**For Individual Content** (`/api/posts/:slug`, `/api/stories/:slug`): +``` +cache-control: s-maxage=604800, stale-while-revalidate=1209600 +content-type: application/json +vary: Accept-Language +``` + +**Cache Strategy Summary:** +- **Listings**: `1h + 2h` (3h total) - **Fast visibility** for new content +- **Blog posts**: `7d + 14d` (21d total) - **Maximum performance** for unchanged content +- **Stories**: `14d + 30d` (44d total) - **Extreme efficiency** for quarterly content +- **Static**: `30d + 60d` (90d total) - **Ultimate performance** for rarely-changed content + +## ๐Ÿ“ **Implementation Order** + +Implement in this exact sequence: + +### **Phase 1: Complete API Routes** +0. **Rename existing**: `api.blog._index.tsx` โ†’ `api.posts._index.tsx` and update route logic +1. **api.posts.$slug.tsx** - Individual blog posts +2. **api.stories._index.tsx** - Stories listing +3. **api.stories.$slug.tsx** - Individual stories +4. **api.search.tsx** - Search functionality +5. **api.tags.tsx** - Tags listing + +### **Phase 2: Test All API Routes** +Verify all routes work correctly before proceeding to Phase 3. + +### **Phase 2.5: Cache Strategy Cleanup** +Simplify cache module after completing API routes but before page refactoring. + +### **Phase 3: Refactor Pages to Use API Routes** +1. **_main.blog._index.tsx** โ†’ Use `/api/posts` +2. **_main.blog.$slug.tsx** โ†’ Use `/api/posts/:slug` +3. **_main.clients._index.tsx** โ†’ Use `/api/stories` +4. **_main.clients.$slug.tsx** โ†’ Use `/api/stories/:slug` + +**Note**: Complete Phase 1 & 2 fully before starting Phase 3 to ensure API foundation is solid. + +## โš ๏ธ **Important Constraints** + +### **DO NOT:** +- Modify `app/modules/cache.ts` - cache logic is finalised +- Modify `app/entry.server.tsx` - response headers working perfectly +- Change existing route files unless absolutely necessary +- Create new content fetching functions - use existing ones in `api.ts` + +### **DO:** +- Follow the exact response format pattern +- Use the IIFE pattern for headers construction (see `api.blog._index.tsx`) +- Apply appropriate cache strategies per content type +- Handle all error cases with proper HTTP status codes +- Test both locally and on deployment before moving to next route + +## ๐Ÿš€ **Deployment Process** + +After implementing each route: +1. Test locally with curl +2. Commit changes +3. Deploy to Vercel +4. Test deployed version with curl +5. Verify cache headers are correct +6. Move to next route + +## ๐Ÿ“ **Reference Files** + +- **Working Example**: `app/routes/api.blog._index.tsx` (rename to `api.posts._index.tsx`) +- **Cache Module**: `app/modules/cache.ts` +- **Content API**: `app/modules/content/api.ts` +- **Entry Server**: `app/entry.server.tsx` + +## ๐ŸŽฏ **Success Criteria** + +### **For API Routes (Phase 1):** +Each route must: +- โœ… Return correct JSON format +- โœ… Include proper cache headers (`s-maxage`, `stale-while-revalidate`, `Vary`) +- โœ… Handle language parameters +- โœ… Support cache bypass with `?refresh=1` +- โœ… Return appropriate HTTP status codes +- โœ… Follow the established patterns exactly + +### **For Page Refactoring (Phase 3):** +Each refactored page must: +- โœ… Call API routes instead of direct backend methods +- โœ… Preserve all existing URL parameter behaviour +- โœ… Handle API errors gracefully with fallback data +- โœ… Maintain same cache strategy for HTML rendering +- โœ… Remove direct imports of backend functions (`fetchBlogposts`, etc.) +- โœ… Pass through all query parameters to API routes + +--- + +## ๐ŸŽฏ **Final Architecture Goal** + +Upon completion, you will achieve a **true API-first architecture**: + +```mermaid +graph TD + A[GitHub API] --> B[API Routes] + B --> C[HTML Pages] + B --> D[External Consumers] + B --> E[Future Mobile Apps] + B --> F[Webhooks/Integrations] + + B -.-> G[Framework-Native Cache] + G -.-> C + G -.-> D +``` + +### **Transformation Summary:** +- **Before**: Multiple data paths, duplicate logic, inconsistent caching, complex cache module +- **After**: Single unified API layer, consistent responses, optimal performance, simplified architecture + +### **Key Metrics to Verify:** +1. **Zero direct backend calls** from HTML pages +2. **Identical response format** for internal/external API consumers +3. **Preserved cache performance** (same header validation) +4. **No regression** in page load times or functionality +5. **50%+ code reduction** in cache module and page loaders +6. **Separated cache concerns** (API vs HTML caching) + +### **Final API Routes:** +``` +/api/posts // Blog posts listing +/api/posts/:slug // Individual blog post +/api/stories // Client stories listing +/api/stories/:slug // Individual client story +/api/search // Search across content +/api/tags // Available tags +``` + +### **Architecture Benefits Achieved:** +- โœ… **API-first design**: External consumers get same data as HTML pages +- โœ… **Simplified cache strategy**: Separate concerns for data vs rendering +- โœ… **Reduced complexity**: 50%+ code reduction across modules +- โœ… **Better maintainability**: Single source of truth for all data access +- โœ… **Future-ready**: Foundation for mobile apps, webhooks, real-time features + +--- + +## ๐Ÿš€ **Future Enhancement: React Query Integration** + +**Your new API-first architecture creates perfect opportunities for React Query!** + +### **Why React Query Makes Sense Now** + +#### **โœ… Perfect Foundation:** +- **Dedicated API endpoints**: React Query needs consistent JSON APIs โœ“ +- **Proper cache headers**: React Query respects HTTP caching โœ“ +- **RESTful structure**: `/api/posts`, `/api/stories` pattern โœ“ +- **Error handling**: Consistent error responses โœ“ + +#### **โœ… High-Value Use Cases:** + +### **1. Progressive Enhancement (HIGH VALUE)** +```typescript +// app/hooks/usePosts.ts +import { useQuery } from '@tanstack/react-query'; + +export function usePosts(initialData, tag, lang = 'fr') { + return useQuery({ + queryKey: ['posts', { tag, lang }], + queryFn: () => fetch(`/api/posts?tag=${tag}&lang=${lang}`).then(r => r.json()), + initialData: { data: initialData }, // SSR data as fallback + staleTime: 3 * 60 * 1000, // 3min (shorter than API cache) + refetchOnWindowFocus: true, + }); +} + +// In your blog page component +function BlogPage() { + const { posts: initialPosts } = useLoaderData(); + const { data, isLoading, error } = usePosts(initialPosts); + + return ( +
+ {isLoading && } + + {error && } +
+ ); +} +``` + +### **2. Real-Time Content Discovery (MEDIUM-HIGH VALUE)** +```typescript +// Auto-refresh listings to show new content without page reload +export function useLivePosts(initialData) { + return useQuery({ + queryKey: ['posts'], + queryFn: () => fetch('/api/posts').then(r => r.json()), + initialData: { data: initialData }, + refetchInterval: 5 * 60 * 1000, // Check for new content every 5min + refetchIntervalInBackground: false, // Only when tab is active + }); +} + +// Result: Users see new articles appear automatically without refresh +``` + +### **3. Enhanced Search Experience (HIGH VALUE)** +```typescript +export function useSearchPosts(query) { + return useQuery({ + queryKey: ['search', query], + queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()), + enabled: query.length > 2, // Only search with 3+ characters + staleTime: 10 * 60 * 1000, // 10min cache for searches + }); +} + +// Instant search with debouncing, caching, and error handling +``` + +### **4. Tag-Based Filtering (MEDIUM VALUE)** +```typescript +export function useFilteredPosts(tag, initialData) { + return useQuery({ + queryKey: ['posts', { tag }], + queryFn: () => fetch(`/api/posts?tag=${tag}`).then(r => r.json()), + initialData: tag ? undefined : { data: initialData }, + placeholderData: (prev) => prev, // Keep previous data while loading + }); +} + +// Smooth tag filtering without full page reload +``` + +### **5. Prefetching & Performance (MEDIUM VALUE)** +```typescript +// Prefetch related content +export function usePrefetchPosts() { + const queryClient = useQueryClient(); + + const prefetchPopularPosts = () => { + queryClient.prefetchQuery({ + queryKey: ['posts', { tag: 'revenue' }], + queryFn: () => fetch('/api/posts?tag=revenue').then(r => r.json()), + staleTime: 60 * 1000, // 1min prefetch + }); + }; + + return { prefetchPopularPosts }; +} + +// Hover-to-prefetch, smart preloading strategies +``` + +### **๐Ÿ“Š Value Analysis for Your Use Case** + +#### **HIGH Value Features:** +- โœ… **Progressive enhancement**: Zero breaking changes, graceful degradation +- โœ… **Real-time discovery**: Users see new articles appear automatically +- โœ… **Enhanced search**: Instant, cached search experience +- โœ… **Better UX**: Loading states, error handling, retry logic + +#### **MEDIUM Value Features:** +- โœ… **Tag filtering**: Client-side filtering without page reload +- โœ… **Prefetching**: Smart content preloading +- โœ… **Background updates**: Keep content fresh silently + +#### **Considerations:** +- โš ๏ธ **Bundle size**: +~13KB gzipped (React Query is lightweight) +- โš ๏ธ **Complexity**: Additional state management layer +- โš ๏ธ **Content site**: Less interactive than typical SPA apps + +### **๐ŸŽฏ Recommended Implementation Strategy** + +#### **Phase 1: Progressive Enhancement Only** +```typescript +// Start simple: enhance existing pages without breaking SSR +const { data: posts } = usePosts(initialServerData); +``` + +#### **Phase 2: Real-Time Features** +```typescript +// Add auto-refresh for new content discovery +const { data: posts } = useLivePosts(initialServerData); +``` + +#### **Phase 3: Interactive Features** +```typescript +// Add search, filtering, prefetching as needed +const { data: searchResults } = useSearchPosts(query); +``` + +### **๐Ÿ”ง Integration Pattern** + +```typescript +// app/components/BlogListWithQuery.tsx +function BlogListWithQuery({ initialPosts, tag }) { + const { data, isLoading, error, isRefetching } = useQuery({ + queryKey: ['posts', { tag }], + queryFn: () => fetch(`/api/posts?tag=${tag || ''}`).then(r => r.json()), + initialData: { data: initialPosts }, + staleTime: 2 * 60 * 1000, // 2min (shorter than API cache) + refetchOnMount: false, // Trust SSR data initially + refetchOnWindowFocus: 'always', // Check for updates on focus + }); + + return ( +
+ {isRefetching && } + + {error && refetch()} />} +
+ ); +} +``` + +### **๐Ÿ“ˆ Expected Benefits** + +#### **User Experience:** +- โœ… **New content appears automatically** (no page refresh needed) +- โœ… **Instant search and filtering** +- โœ… **Better loading states** and error handling +- โœ… **Optimistic interactions** for future features + +#### **Performance:** +- โœ… **Reduced server load**: Client-side caching layer +- โœ… **Smart refetching**: Only when necessary +- โœ… **Prefetching**: Content ready before users need it + +#### **Developer Experience:** +- โœ… **Built on your API foundation**: Zero architectural changes needed +- โœ… **Progressive enhancement**: Add features incrementally +- โœ… **Excellent DevTools**: Debugging and monitoring +- โœ… **Future-ready**: Foundation for any interactive features + +### **๐Ÿš€ Conclusion** + +**YES, React Query would bring significant value**, especially for: + +1. **Real-time content discovery** (perfect for your publishing workflow) +2. **Enhanced search experience** (huge UX improvement) +3. **Progressive enhancement** (zero risk, pure upside) +4. **Future interactive features** (comments, likes, bookmarks, etc.) + +**Recommendation**: Start with **Phase 1 (Progressive Enhancement)** after completing the API routes refactoring. It's a natural next step that builds perfectly on your new API-first architecture! + +--- + +**Start with Phase 1: `api.posts.$slug.tsx` and work through systematically. Complete each phase fully before proceeding to the next.** From 4c5d1cf2f8a2078336f6d0c832e9bd2c23b1ced8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?j=C3=A9r=C3=B4me=20boileux?= Date: Fri, 22 Aug 2025 17:32:18 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20add=20api=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”ง Technical Implementation Cache Module Improvements: - Separated concerns: getApiCacheHeaders() vs getHtmlCacheHeaders() - Added new strategies: blogListing, storyListing for differentiated caching - Maintained backward compatibility: Legacy functions preserved - Optimized durations: Based on content publishing frequency analysis Route Features: - โœ… Proper error handling with appropriate HTTP status codes - โœ… Language parameter support (lang=fr|en) - โœ… Cache bypass support (refresh=1) - โœ… Tag filtering for posts and stories - โœ… Advanced search with relevance scoring - โœ… Comprehensive tags API with counts and metadata --- app/modules/cache.ts | 105 ++++++++-------- app/routes/api.posts.$slug.tsx | 79 ++++++++++++ app/routes/api.posts._index.tsx | 67 +++++++++++ app/routes/api.search.tsx | 192 ++++++++++++++++++++++++++++++ app/routes/api.stories.$slug.tsx | 76 ++++++++++++ app/routes/api.stories._index.tsx | 70 +++++++++++ app/routes/api.tags.tsx | 125 +++++++++++++++++++ 7 files changed, 666 insertions(+), 48 deletions(-) create mode 100644 app/routes/api.posts.$slug.tsx create mode 100644 app/routes/api.posts._index.tsx create mode 100644 app/routes/api.search.tsx create mode 100644 app/routes/api.stories.$slug.tsx create mode 100644 app/routes/api.stories._index.tsx create mode 100644 app/routes/api.tags.tsx diff --git a/app/modules/cache.ts b/app/modules/cache.ts index 5a288e1..52698c9 100644 --- a/app/modules/cache.ts +++ b/app/modules/cache.ts @@ -1,31 +1,31 @@ /** - * Cache strategy for React Router loaders - * - * - Local filesystem: No caching - * - GitHub content: Vercel Edge Cache - * - Testing: ?refresh=1 bypasses cache + * Simplified cache strategy for API-first architecture */ - -import type { LoaderFunctionArgs } from 'react-router'; import { getPrivateEnvVars } from './env.server'; -/** - * Cache strategy types for different content - */ -export type CacheStrategy = 'blogPost' | 'story' | 'static'; +export type CacheStrategy = + | 'blogListing' + | 'blogPost' + | 'storyListing' + | 'story' + | 'static'; -/** - * Cache configuration for each strategy type - */ +// BALANCED: Aggressive cache for individual content, shorter for listings to show new content const CACHE_CONFIG = { - blogPost: { maxAge: 3600, staleWhileRevalidate: 86400 }, // 1h + 24h - story: { maxAge: 3600, staleWhileRevalidate: 86400 }, // 1h + 24h - static: { maxAge: 86400, staleWhileRevalidate: 604800 }, // 24h + 7d + // Blog post listings: Short cache to show new articles quickly + blogListing: { maxAge: 3600, staleWhileRevalidate: 7200 }, // 1h + 2h + // Individual blog posts: Long cache since content doesn't change once published + blogPost: { maxAge: 604800, staleWhileRevalidate: 1209600 }, // 7d + 14d + + // Story listings: Short cache to show new stories quickly + storyListing: { maxAge: 3600, staleWhileRevalidate: 7200 }, // 1h + 2h + // Individual stories: Long cache since content doesn't change once published + story: { maxAge: 1209600, staleWhileRevalidate: 2592000 }, // 14d + 30d + + // Static content: cache for 30 days + 60 days stale = 90 days total + static: { maxAge: 2592000, staleWhileRevalidate: 5184000 }, // 30d + 60d } as const; -/** - * Check if using GitHub as content source - */ function isUsingGitHub(): boolean { try { const { readContentFrom } = getPrivateEnvVars(); @@ -35,26 +35,23 @@ function isUsingGitHub(): boolean { } } -/** - * Check if request should bypass cache (refresh parameter) - */ export function shouldBypassCache(request: Request): boolean { const url = new URL(request.url); return url.searchParams.has('refresh'); } -/** - * Build cache control header based on strategy - */ function buildCacheControl(strategy: CacheStrategy): string { const { maxAge, staleWhileRevalidate } = CACHE_CONFIG[strategy]; return `s-maxage=${maxAge}, stale-while-revalidate=${staleWhileRevalidate}`; } /** - * Get cache headers for GitHub content or local filesystem + * Cache headers for API routes (data caching) */ -export function getCacheHeaders(strategy: CacheStrategy, bypassCache = false) { +export function getApiCacheHeaders( + strategy: CacheStrategy, + bypassCache = false, +) { if (bypassCache) { return { 'Cache-Control': 'no-cache, no-store, must-revalidate', @@ -65,15 +62,12 @@ export function getCacheHeaders(strategy: CacheStrategy, bypassCache = false) { if (isUsingGitHub()) { const cacheControl = buildCacheControl(strategy); return { - // Standard header for browsers and other CDNs 'Cache-Control': cacheControl, - // Vercel-specific header (highest priority, Vercel-only) 'Vercel-CDN-Cache-Control': cacheControl, Vary: 'Accept-Language', }; } - // Local filesystem: no caching return { 'Cache-Control': 'no-cache', 'Vercel-CDN-Cache-Control': 'no-cache', @@ -81,28 +75,47 @@ export function getCacheHeaders(strategy: CacheStrategy, bypassCache = false) { } /** - * Hybrid loader that returns data for meta functions with cache headers - * - * This is the main loader function used throughout the application. - * It handles both data access (for meta functions) and caching. - * - * Note: Cache headers are now applied at the HTML response level in entry.server.tsx + * Cache headers for HTML pages (rendering caching) + * Shorter cache for faster content updates */ +export function getHtmlCacheHeaders(bypassCache = false) { + if (bypassCache) { + return { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Vercel-CDN-Cache-Control': 'no-cache, no-store, must-revalidate', + }; + } + + if (isUsingGitHub()) { + return { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=3600', // 5min + 1h + 'Vercel-CDN-Cache-Control': 's-maxage=300, stale-while-revalidate=3600', + Vary: 'Accept-Language', + }; + } + + return { + 'Cache-Control': 'no-cache', + 'Vercel-CDN-Cache-Control': 'no-cache', + }; +} + +// Legacy functions for backward compatibility - DEPRECATED +export function getCacheHeaders(strategy: CacheStrategy, bypassCache = false) { + return getApiCacheHeaders(strategy, bypassCache); +} + export function createHybridLoader( - fetcher: (args: LoaderFunctionArgs) => Promise, - _strategy?: CacheStrategy, // Keep for backward compatibility but no longer used + fetcher: (args: any) => Promise, + _strategy?: CacheStrategy, ) { - return async (args: LoaderFunctionArgs) => { + return async (args: any) => { const data = await fetcher(args); return data; }; } -/** - * Determine cache strategy based on pathname - */ export function getCacheStrategyForPath(pathname: string): CacheStrategy { - // Remove language prefix if present const pathWithoutLang = pathname.replace(/^\/(en|fr)/, '') || '/'; if (pathWithoutLang.startsWith('/blog')) { @@ -113,13 +126,9 @@ export function getCacheStrategyForPath(pathname: string): CacheStrategy { return 'story'; } - // Default to static for homepage and other pages return 'static'; } -/** - * Log cache strategy being used on server startup - */ export function logCacheStrategy(): void { const strategy = isUsingGitHub() ? 'Vercel Edge Cache' diff --git a/app/routes/api.posts.$slug.tsx b/app/routes/api.posts.$slug.tsx new file mode 100644 index 0000000..834fd28 --- /dev/null +++ b/app/routes/api.posts.$slug.tsx @@ -0,0 +1,79 @@ +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchBlogpost } from '~/modules/content'; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const url = new URL(request.url); + + // Extract parameters + const lang = url.searchParams.get('lang') || 'fr'; + const slug = params?.slug; + + if (!slug) { + return new Response( + JSON.stringify({ + data: null, + isError: true, + error: 'Missing slug parameter', + }), + { + status: 400, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'blogPost', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Fetch data using existing content API + const [status, state, blogData] = await fetchBlogpost(slug, lang); + + // Handle errors + if (status !== 200 || !blogData) { + console.error(`Failed to fetch blog post: ${state}`); + return new Response( + JSON.stringify({ data: null, isError: true, error: state }), + { + status: status === 404 ? 404 : 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'blogPost', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Return success response with cache headers + const cacheHeaders = getApiCacheHeaders( + 'blogPost', + shouldBypassCache(request), + ); + + return new Response(JSON.stringify({ data: blogData, isError: false }), { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }); +} diff --git a/app/routes/api.posts._index.tsx b/app/routes/api.posts._index.tsx new file mode 100644 index 0000000..2ec148f --- /dev/null +++ b/app/routes/api.posts._index.tsx @@ -0,0 +1,67 @@ +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchBlogposts } from '~/modules/content'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const tag = url.searchParams.get('tag'); + const lang = url.searchParams.get('lang') || 'fr'; + + const [status, state, blogData] = await fetchBlogposts(lang); + + // Handle errors gracefully + if (status !== 200 || !blogData) { + console.error(`Failed to fetch blog posts: ${state}`); + return new Response( + JSON.stringify({ data: null, isError: true, error: state }), + { + status: status === 404 ? 404 : 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'blogListing', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Filter and sort posts + const filteredPosts = tag + ? blogData.filter((entry) => entry.frontmatter.tags.includes(tag)) + : blogData; + + const posts = filteredPosts + .map((entry) => ({ + ...entry, + _sortDate: new Date(entry.frontmatter.date).getTime(), + })) + .sort((a, b) => b._sortDate - a._sortDate) + .map(({ _sortDate, ...entry }) => entry); + + // API-specific cache headers (short cache for new content visibility) + const cacheHeaders = getApiCacheHeaders( + 'blogListing', + shouldBypassCache(request), + ); + + return new Response( + JSON.stringify({ data: posts, isError: false, total: posts.length }), + { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); +} diff --git a/app/routes/api.search.tsx b/app/routes/api.search.tsx new file mode 100644 index 0000000..37d305b --- /dev/null +++ b/app/routes/api.search.tsx @@ -0,0 +1,192 @@ +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchBlogposts, fetchStories } from '~/modules/content'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + + // Extract parameters + const query = url.searchParams.get('q'); + const type = url.searchParams.get('type'); // 'posts', 'stories', or 'all' (default) + const lang = url.searchParams.get('lang') || 'fr'; + + if (!query || query.length < 2) { + return new Response( + JSON.stringify({ + data: null, + isError: true, + error: + 'Query parameter "q" is required and must be at least 2 characters', + }), + { + status: 400, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'static', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + const searchTerm = query.toLowerCase(); + const results: any[] = []; + + try { + // Search in blog posts if type is 'posts' or 'all' + if (!type || type === 'all' || type === 'posts') { + const [blogStatus, _blogState, blogData] = await fetchBlogposts(lang); + if (blogStatus === 200 && blogData) { + const matchingPosts = blogData.filter((post) => { + const title = post.frontmatter.title?.toLowerCase() || ''; + const description = post.frontmatter.description?.toLowerCase() || ''; + const content = String(post.content || '').toLowerCase(); + const tags = post.frontmatter.tags?.join(' ').toLowerCase() || ''; + + return ( + title.includes(searchTerm) || + description.includes(searchTerm) || + content.includes(searchTerm) || + tags.includes(searchTerm) + ); + }); + + matchingPosts.forEach((post) => { + results.push({ + ...post, + type: 'post', + score: calculateScore(post, searchTerm), + }); + }); + } + } + + // Search in stories if type is 'stories' or 'all' + if (!type || type === 'all' || type === 'stories') { + const [storiesStatus, _storiesState, storiesData] = + await fetchStories(lang); + if (storiesStatus === 200 && storiesData) { + const matchingStories = storiesData.filter((story) => { + const title = story.frontmatter.title?.toLowerCase() || ''; + const description = + story.frontmatter.description?.toLowerCase() || ''; + const content = String(story.content || '').toLowerCase(); + const tags = story.frontmatter.tags?.join(' ').toLowerCase() || ''; + + return ( + title.includes(searchTerm) || + description.includes(searchTerm) || + content.includes(searchTerm) || + tags.includes(searchTerm) + ); + }); + + matchingStories.forEach((story) => { + results.push({ + ...story, + type: 'story', + score: calculateScore(story, searchTerm), + }); + }); + } + } + + // Sort by relevance score (highest first) + const sortedResults = results + .sort((a, b) => b.score - a.score) + .map(({ score, ...item }) => item); // Remove score from final results + + // Return success response with cache headers + const cacheHeaders = getApiCacheHeaders( + 'static', + shouldBypassCache(request), + ); + + return new Response( + JSON.stringify({ + data: sortedResults, + isError: false, + total: sortedResults.length, + query: query, + }), + { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } catch (error) { + console.error('Search failed:', error); + return new Response( + JSON.stringify({ + data: null, + isError: true, + error: 'Search failed due to internal error', + }), + { + status: 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'static', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } +} + +/** + * Calculate relevance score for search results + */ +function calculateScore(item: any, searchTerm: string): number { + let score = 0; + const title = item.frontmatter.title?.toLowerCase() || ''; + const description = item.frontmatter.description?.toLowerCase() || ''; + const tags = item.frontmatter.tags?.join(' ').toLowerCase() || ''; + + // Title matches get highest score + if (title.includes(searchTerm)) { + score += 10; + if (title.startsWith(searchTerm)) score += 5; + } + + // Description matches get medium score + if (description.includes(searchTerm)) { + score += 5; + } + + // Tag matches get good score + if (tags.includes(searchTerm)) { + score += 7; + } + + // Content matches get base score + const content = String(item.content || '').toLowerCase(); + if (content.includes(searchTerm)) { + score += 2; + // Boost score for multiple content matches + const matches = (content.match(new RegExp(searchTerm, 'g')) || []).length; + score += Math.min(matches - 1, 3); // Max 3 extra points for multiple matches + } + + return score; +} diff --git a/app/routes/api.stories.$slug.tsx b/app/routes/api.stories.$slug.tsx new file mode 100644 index 0000000..0979e09 --- /dev/null +++ b/app/routes/api.stories.$slug.tsx @@ -0,0 +1,76 @@ +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchStory } from '~/modules/content'; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const url = new URL(request.url); + + // Extract parameters + const lang = url.searchParams.get('lang') || 'fr'; + const slug = params?.slug; + + if (!slug) { + return new Response( + JSON.stringify({ + data: null, + isError: true, + error: 'Missing slug parameter', + }), + { + status: 400, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'story', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Fetch data using existing content API + const [status, state, storyData] = await fetchStory(slug, lang); + + // Handle errors + if (status !== 200 || !storyData) { + console.error(`Failed to fetch story: ${state}`); + return new Response( + JSON.stringify({ data: null, isError: true, error: state }), + { + status: status === 404 ? 404 : 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'story', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Return success response with cache headers + const cacheHeaders = getApiCacheHeaders('story', shouldBypassCache(request)); + + return new Response(JSON.stringify({ data: storyData, isError: false }), { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }); +} diff --git a/app/routes/api.stories._index.tsx b/app/routes/api.stories._index.tsx new file mode 100644 index 0000000..d96d1c5 --- /dev/null +++ b/app/routes/api.stories._index.tsx @@ -0,0 +1,70 @@ +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchStories } from '~/modules/content'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + + // Extract parameters + const tag = url.searchParams.get('tag'); + const lang = url.searchParams.get('lang') || 'fr'; + + // Fetch data using existing content API + const [status, state, storiesData] = await fetchStories(lang); + + // Handle errors + if (status !== 200 || !storiesData) { + console.error(`Failed to fetch stories: ${state}`); + return new Response( + JSON.stringify({ data: null, isError: true, error: state }), + { + status: status === 404 ? 404 : 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'storyListing', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } + + // Filter and sort stories + const filteredStories = tag + ? storiesData.filter((entry) => entry.frontmatter.tags?.includes(tag)) + : storiesData; + + const stories = filteredStories + .map((entry) => ({ + ...entry, + _sortDate: new Date(entry.frontmatter.date).getTime(), + })) + .sort((a, b) => b._sortDate - a._sortDate) + .map(({ _sortDate, ...entry }) => entry); + + // Return success response with cache headers + const cacheHeaders = getApiCacheHeaders( + 'storyListing', + shouldBypassCache(request), + ); + + return new Response( + JSON.stringify({ data: stories, isError: false, total: stories.length }), + { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); +} diff --git a/app/routes/api.tags.tsx b/app/routes/api.tags.tsx new file mode 100644 index 0000000..36a9a2d --- /dev/null +++ b/app/routes/api.tags.tsx @@ -0,0 +1,125 @@ +import { type LoaderFunctionArgs } from 'react-router'; +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; +import { fetchBlogposts, fetchStories } from '~/modules/content'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + + // Extract parameters + const type = url.searchParams.get('type'); // 'posts', 'stories', or 'all' (default) + const lang = url.searchParams.get('lang') || 'fr'; + + const allTags = new Set(); + const tagCounts: Record< + string, + { posts: number; stories: number; total: number } + > = {}; + + try { + // Collect tags from blog posts if type is 'posts' or 'all' + if (!type || type === 'all' || type === 'posts') { + const [blogStatus, _blogState, blogData] = await fetchBlogposts(lang); + if (blogStatus === 200 && blogData) { + blogData.forEach((post) => { + const tags = post.frontmatter.tags || []; + tags.forEach((tag) => { + allTags.add(tag); + if (!tagCounts[tag]) { + tagCounts[tag] = { posts: 0, stories: 0, total: 0 }; + } + tagCounts[tag].posts += 1; + tagCounts[tag].total += 1; + }); + }); + } + } + + // Collect tags from stories if type is 'stories' or 'all' + if (!type || type === 'all' || type === 'stories') { + const [storiesStatus, _storiesState, storiesData] = + await fetchStories(lang); + if (storiesStatus === 200 && storiesData) { + storiesData.forEach((story) => { + const tags = story.frontmatter.tags || []; + tags.forEach((tag) => { + allTags.add(tag); + if (!tagCounts[tag]) { + tagCounts[tag] = { posts: 0, stories: 0, total: 0 }; + } + tagCounts[tag].stories += 1; + tagCounts[tag].total += 1; + }); + }); + } + } + + // Convert to sorted array with metadata + const tagsWithMetadata = Array.from(allTags) + .map((tag) => ({ + name: tag, + slug: tag.toLowerCase().replace(/\s+/g, '-'), + counts: tagCounts[tag], + })) + .sort((a, b) => { + // Sort by total count descending, then alphabetically + if (b.counts.total !== a.counts.total) { + return b.counts.total - a.counts.total; + } + return a.name.localeCompare(b.name); + }); + + // Simple array for minimal response (if client doesn't need counts) + const includeMetadata = url.searchParams.get('metadata') !== 'false'; + const tagsData = includeMetadata + ? tagsWithMetadata + : Array.from(allTags).sort(); + + // Return success response with cache headers + const cacheHeaders = getApiCacheHeaders( + 'static', + shouldBypassCache(request), + ); + + return new Response( + JSON.stringify({ + data: tagsData, + isError: false, + total: allTags.size, + }), + { + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } catch (error) { + console.error('Failed to fetch tags:', error); + return new Response( + JSON.stringify({ + data: null, + isError: true, + error: 'Failed to fetch tags due to internal error', + }), + { + status: 500, + headers: (() => { + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + const cacheHeaders = getApiCacheHeaders( + 'static', + shouldBypassCache(request), + ); + for (const [key, value] of Object.entries(cacheHeaders)) { + headers.set(key, value); + } + return headers; + })(), + }, + ); + } +} From 89bfa74455daa8d8a98839eeb64ed7b2cc4b8d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?j=C3=A9r=C3=B4me=20boileux?= Date: Fri, 22 Aug 2025 19:07:35 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20api=20routes=20usage?= =?UTF-8?q?=20into=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE_CODE_PROMPT.md | 166 ++++++++++++++++++++++++++++ app/entry.server.tsx | 8 +- app/modules/cache.ts | 32 +----- app/routes/_main.($lang)._index.tsx | 50 ++++++--- app/routes/_main.blog.$slug.tsx | 56 ++++++---- app/routes/_main.blog._index.tsx | 52 ++++----- app/routes/_main.clients.$slug.tsx | 56 ++++++---- app/routes/_main.clients._index.tsx | 63 ++++++----- 8 files changed, 340 insertions(+), 143 deletions(-) create mode 100644 CLAUDE_CODE_PROMPT.md diff --git a/CLAUDE_CODE_PROMPT.md b/CLAUDE_CODE_PROMPT.md new file mode 100644 index 0000000..d2b470b --- /dev/null +++ b/CLAUDE_CODE_PROMPT.md @@ -0,0 +1,166 @@ +# Claude Code Agent: API Routes Refactoring Implementation + +## ๐ŸŽฏ **Mission** + +Transform this React Router v7 application into a **true API-first architecture** by implementing systematic API routes refactoring following the comprehensive guide provided. + +## ๐Ÿ“‹ **Current State** + +- โœ… **Working foundation**: Framework-native cache strategy with `Vercel-CDN-Cache-Control` +- โœ… **First API route**: `/api/blog` working with proper cache headers +- โœ… **Content system**: GitHub-based content with `app/modules/content/api.ts` +- โœ… **Cache module**: `app/modules/cache.ts` with caching infrastructure + +**Test current API**: `curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/blog` + +## ๐Ÿ“– **Implementation Guide** + +**READ THIS FIRST**: `API_ROUTES_REFACTORING_GUIDE.md` (1,018 lines) contains: +- Complete technical specifications +- Step-by-step implementation instructions +- Code templates for all patterns +- Testing protocols for each phase +- Cache strategy optimization +- Success criteria and validation + +## ๐Ÿš€ **Your Implementation Tasks** + +### **Phase 1: Complete API Routes (START HERE)** + +**CRITICAL FIRST STEP**: +```bash +# Rename existing API route +mv app/routes/api.blog._index.tsx app/routes/api.posts._index.tsx +# Update internal route logic to use new naming +``` + +**Then implement these routes in order**: +1. **`api.posts.$slug.tsx`** - Individual blog posts +2. **`api.stories._index.tsx`** - Client stories listing +3. **`api.stories.$slug.tsx`** - Individual stories +4. **`api.search.tsx`** - Search functionality +5. **`api.tags.tsx`** - Tags listing + +**Pattern**: Follow the exact template in the guide (lines 72-126) for each route. + +### **Phase 2: Test All Routes** +Verify each route using the testing protocols (lines 634-639 in guide). + +### **Phase 2.5: Cache Strategy Cleanup** +Simplify `app/modules/cache.ts` following lines 292-586 in the guide. + +### **Phase 3: Refactor Pages** +Convert HTML pages to call API routes instead of direct backend calls. + +## ๐Ÿ”ง **Key Technical Requirements** + +### **Cache Strategy** (CRITICAL): +```typescript +// Use differentiated caching: +'blogListing' // 1h + 2h (for new content visibility) +'blogPost' // 7d + 14d (for performance) +'storyListing' // 1h + 2h (for new content visibility) +'story' // 14d + 30d (for performance) +'static' // 30d + 60d (for maximum efficiency) +``` + +### **API Response Format** (MANDATORY): +```typescript +// Success +{ data: T, isError: false, total?: number } + +// Error +{ data: null, isError: true, error: string } +``` + +### **Route Template** (EXACT PATTERN): +```typescript +import { getApiCacheHeaders, shouldBypassCache } from '~/modules/cache'; + +export async function loader({ request, params }: LoaderFunctionArgs) { + // 1. Extract parameters + // 2. Fetch data using existing content API + // 3. Handle errors with proper HTTP status + // 4. Apply cache headers using getApiCacheHeaders() + // 5. Return Response with proper JSON and headers +} +``` + +## โš ๏ธ **Critical Constraints** + +### **DO NOT MODIFY**: +- `app/entry.server.tsx` (cache headers working perfectly) +- `app/modules/content/api.ts` (use existing functions) +- Cache header application logic (already optimized) + +### **DO FOLLOW**: +- Exact response format (see guide lines 270-283) +- Cache strategy selection (see guide lines 284-291) +- Error handling patterns (see guide lines 94-107) +- Testing protocols (see guide lines 634-665) + +## ๐Ÿงช **Testing Requirements** + +For each route implemented: +```bash +# Test locally +curl -I http://localhost:5173/api/posts + +# Test deployed +curl -I https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts + +# Verify JSON response +curl -s https://ocobo-git-api-routes-wabdsgn.vercel.app/api/posts | head -20 +``` + +**Expected headers**: +- `cache-control: s-maxage=3600, stale-while-revalidate=7200` (listings) +- `cache-control: s-maxage=604800, stale-while-revalidate=1209600` (individual content) +- `content-type: application/json` +- `vary: Accept-Language` + +## ๐Ÿ“Š **Success Criteria** + +### **Phase 1 Complete When**: +- โœ… All 6 API routes return proper JSON with cache headers +- โœ… All routes handle errors with appropriate HTTP status codes +- โœ… Testing protocols pass for each route +- โœ… Cache headers match expected values by content type + +### **Phase 2.5 Complete When**: +- โœ… `cache.ts` reduced from 129 โ†’ ~60 lines +- โœ… `createHybridLoader` removed entirely +- โœ… Separate `getApiCacheHeaders()` vs `getHtmlCacheHeaders()` functions + +### **Phase 3 Complete When**: +- โœ… All HTML pages call API routes instead of direct backend functions +- โœ… No `fetchBlogposts`, `fetchStories` imports in page components +- โœ… Consistent error handling across all consumers +- โœ… Zero regression in page functionality + +## ๐ŸŽฏ **Key Benefits You're Achieving** + +- **API-first architecture**: External consumers get same data as HTML pages +- **50%+ code reduction**: Simplified cache module and page loaders +- **Optimal performance**: 95%+ GitHub API call reduction with smart caching +- **New content visibility**: Users see new articles within 1-3 hours automatically +- **Future-ready**: Foundation for React Query, mobile apps, webhooks + +## ๐Ÿ“ **Implementation Notes** + +1. **Start simple**: Begin with Phase 1, test thoroughly +2. **Follow patterns**: Use the exact templates provided in the guide +3. **Test frequently**: Verify each route before moving to the next +4. **Read the guide**: All edge cases and detailed instructions are documented +5. **Incremental approach**: Complete each phase fully before proceeding + +## ๐Ÿ”— **Reference** + +- **Complete Guide**: `API_ROUTES_REFACTORING_GUIDE.md` +- **Working Example**: `app/routes/api.blog._index.tsx` (to be renamed) +- **Content Functions**: `app/modules/content/api.ts` +- **Cache Module**: `app/modules/cache.ts` + +--- + +**Ready to implement? Start with renaming the existing API route and then follow Phase 1 step-by-step. The comprehensive guide has all the details you need for successful implementation.** diff --git a/app/entry.server.tsx b/app/entry.server.tsx index ff5d231..b67ba3c 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -13,8 +13,7 @@ import { ServerRouter } from 'react-router'; import * as i18n from '~/localization/i18n'; // your i18n configuration file import i18nServer from '~/localization/i18n.server'; import { - getCacheHeaders, - getCacheStrategyForPath, + getHtmlCacheHeaders, logCacheStrategy, shouldBypassCache, } from '~/modules/cache'; @@ -67,10 +66,9 @@ export default async function handleRequest( const stream = createReadableStreamFromReadable(body); responseHeaders.set('Content-Type', 'text/html'); - // Apply framework-native cache headers at response level - const cacheStrategy = getCacheStrategyForPath(pathname); + // Apply simplified HTML cache headers at response level const bypassCache = shouldBypassCache(request); - const cacheHeaders = getCacheHeaders(cacheStrategy, bypassCache); + const cacheHeaders = getHtmlCacheHeaders(bypassCache); // Set cache headers on response (bypasses Vercel framework-level override) for (const [key, value] of Object.entries(cacheHeaders)) { diff --git a/app/modules/cache.ts b/app/modules/cache.ts index 52698c9..348c821 100644 --- a/app/modules/cache.ts +++ b/app/modules/cache.ts @@ -100,34 +100,10 @@ export function getHtmlCacheHeaders(bypassCache = false) { }; } -// Legacy functions for backward compatibility - DEPRECATED -export function getCacheHeaders(strategy: CacheStrategy, bypassCache = false) { - return getApiCacheHeaders(strategy, bypassCache); -} - -export function createHybridLoader( - fetcher: (args: any) => Promise, - _strategy?: CacheStrategy, -) { - return async (args: any) => { - const data = await fetcher(args); - return data; - }; -} - -export function getCacheStrategyForPath(pathname: string): CacheStrategy { - const pathWithoutLang = pathname.replace(/^\/(en|fr)/, '') || '/'; - - if (pathWithoutLang.startsWith('/blog')) { - return 'blogPost'; - } - - if (pathWithoutLang.startsWith('/clients')) { - return 'story'; - } - - return 'static'; -} +// Removed deprecated functions: +// - createHybridLoader: Pages now use standard loaders calling API routes +// - getCacheHeaders: Replaced by getApiCacheHeaders/getHtmlCacheHeaders +// - getCacheStrategyForPath: HTML pages use unified HTML caching export function logCacheStrategy(): void { const strategy = isUsingGitHub() diff --git a/app/routes/_main.($lang)._index.tsx b/app/routes/_main.($lang)._index.tsx index b7f402f..8b5d00a 100644 --- a/app/routes/_main.($lang)._index.tsx +++ b/app/routes/_main.($lang)._index.tsx @@ -19,24 +19,41 @@ import { } from '~/components/homepage'; import { Loader } from '~/components/ui/Loader'; import i18nServer from '~/localization/i18n.server'; -import { createHybridLoader } from '~/modules/cache'; -import { fetchStories } from '~/modules/content'; import type { MarkdocFile, StoryFrontmatter } from '~/types'; import { getLang } from '~/utils/lang'; import { getMetaTags } from '~/utils/metatags'; import { redirectWithLocale } from '~/utils/redirections'; import { getImageOgFullPath } from '~/utils/url'; -export const loader = createHybridLoader( - async (args: LoaderFunctionArgs) => { - await redirectWithLocale(args); - const t = await i18nServer.getFixedT(getLang(args.params), 'home'); +export async function loader(args: LoaderFunctionArgs) { + await redirectWithLocale(args); + const t = await i18nServer.getFixedT(getLang(args.params), 'home'); - const [status, state, storiesData] = await fetchStories(); + const url = new URL(args.request.url); + const lang = getLang(args.params) || 'fr'; - // Handle errors gracefully - if (status !== 200 || !storiesData) { - console.error(`Failed to fetch stories: ${state}`); + // Build API URL with same origin (internal call) + const apiUrl = new URL('/api/stories', url.origin); + if (lang !== 'fr') apiUrl.searchParams.set('lang', lang); + + try { + // Call internal API route (optimized on Vercel) + const response = await fetch(apiUrl.toString()); + + if (!response.ok) { + console.error(`API call failed: ${response.status}`); + return { + title: t('meta.title'), + description: t('meta.description'), + stories: [], + ogImageSrc: getImageOgFullPath('homepage', args.request.url), + }; + } + + const { data: storiesData, isError } = await response.json(); + + if (isError || !storiesData) { + console.error('API returned error'); return { title: t('meta.title'), description: t('meta.description'), @@ -77,9 +94,16 @@ export const loader = createHybridLoader( stories, ogImageSrc: getImageOgFullPath('homepage', args.request.url), }; - }, - 'story', // Use story cache strategy -); + } catch (error) { + console.error('Failed to fetch from API:', error); + return { + title: t('meta.title'), + description: t('meta.description'), + stories: [], + ogImageSrc: getImageOgFullPath('homepage', args.request.url), + }; + } +} export const meta: MetaFunction = ({ data, params }) => { if (!data) { diff --git a/app/routes/_main.blog.$slug.tsx b/app/routes/_main.blog.$slug.tsx index 1d32b19..9a1028c 100644 --- a/app/routes/_main.blog.$slug.tsx +++ b/app/routes/_main.blog.$slug.tsx @@ -1,4 +1,4 @@ -import { type LoaderFunctionArgs, MetaFunction, data } from 'react-router'; +import { type LoaderFunctionArgs, MetaFunction } from 'react-router'; import { useLoaderData } from 'react-router'; import { css } from '@ocobo/styled-system/css'; @@ -6,8 +6,6 @@ import { css } from '@ocobo/styled-system/css'; import { BlogArticle } from '~/components/blog'; import { Container } from '~/components/ui/Container'; import { ScrollProgressBar } from '~/components/ui/ScrollProgressBar'; -import { createHybridLoader } from '~/modules/cache'; -import { fetchBlogpost } from '~/modules/content'; import { getLang } from '~/utils/lang'; import { getMetaTags } from '~/utils/metatags'; @@ -15,32 +13,46 @@ export const handle = { scripts: () => [{ src: 'https://player.ausha.co/ausha-player.js' }], }; -export const loader = createHybridLoader( - async ({ params }: LoaderFunctionArgs) => { - const { slug } = params; +export async function loader({ params, request }: LoaderFunctionArgs) { + const { slug } = params; - if (!slug) { - throw new Response('Not Found', { status: 404 }); + if (!slug) { + throw new Response('Not Found', { status: 404 }); + } + + const url = new URL(request.url); + const lang = url.searchParams.get('lang') || 'fr'; + + // Build API URL with same origin (internal call) + const apiUrl = new URL(`/api/posts/${slug}`, url.origin); + if (lang !== 'fr') apiUrl.searchParams.set('lang', lang); + + try { + // Call internal API route (optimized on Vercel) + const response = await fetch(apiUrl.toString()); + + if (!response.ok) { + if (response.status === 404) { + throw new Response('Not Found', { status: 404 }); + } + throw new Response('Internal Server Error', { status: 500 }); } - const [status, _state, article] = await fetchBlogpost(slug); + const { data: article, isError } = await response.json(); - if (status !== 200 || !article) { + if (isError || !article) { throw new Response('Not Found', { status: 404 }); } - return data( - { article }, - { - headers: { - 'Cache-Control': 's-maxage=3600, stale-while-revalidate=86400', - Vary: 'Accept-Language', - }, - }, - ); - }, - 'blogPost', // Use blog post cache strategy -); + return { article }; + } catch (error) { + if (error instanceof Response) { + throw error; + } + console.error('Failed to fetch from API:', error); + throw new Response('Internal Server Error', { status: 500 }); + } +} export const meta: MetaFunction = ({ data, params }) => { return getMetaTags({ diff --git a/app/routes/_main.blog._index.tsx b/app/routes/_main.blog._index.tsx index 0c07b84..c131b72 100644 --- a/app/routes/_main.blog._index.tsx +++ b/app/routes/_main.blog._index.tsx @@ -8,40 +8,42 @@ import { css } from '@ocobo/styled-system/css'; import { BlogList } from '~/components/blog'; import { Container } from '~/components/ui/Container'; import { Loader } from '~/components/ui/Loader'; -import { createHybridLoader } from '~/modules/cache'; -import { fetchBlogposts } from '~/modules/content'; import { getMetaTags } from '~/utils/metatags'; -export const loader = createHybridLoader( - async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - const tag = url.searchParams.get('tag'); +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); - const [status, state, blogData] = await fetchBlogposts(); + // Extract existing parameters + const tag = url.searchParams.get('tag'); + const lang = url.searchParams.get('lang') || 'fr'; - // Handle errors gracefully - if (status !== 200 || !blogData) { - console.error(`Failed to fetch blog posts: ${state}`); + // Build API URL with same origin (internal call) + const apiUrl = new URL('/api/posts', url.origin); + if (tag) apiUrl.searchParams.set('tag', tag); + if (lang !== 'fr') apiUrl.searchParams.set('lang', lang); + + try { + // Call internal API route (optimized on Vercel) + const response = await fetch(apiUrl.toString()); + + if (!response.ok) { + console.error(`API call failed: ${response.status}`); return { posts: [], isError: true }; } - // Filter and sort posts - const filteredPosts = tag - ? blogData.filter((entry) => entry.frontmatter.tags.includes(tag)) - : blogData; + const { data, isError } = await response.json(); - const posts = filteredPosts - .map((entry) => ({ - ...entry, - _sortDate: new Date(entry.frontmatter.date).getTime(), - })) - .sort((a, b) => b._sortDate - a._sortDate) - .map(({ _sortDate, ...entry }) => entry); + if (isError) { + console.error('API returned error'); + return { posts: [], isError: true }; + } - return { posts, isError: false }; - }, - 'blogPost', // Use blog post cache strategy -); + return { posts: data || [], isError: false }; + } catch (error) { + console.error('Failed to fetch from API:', error); + return { posts: [], isError: true }; + } +} // Headers now handled by entry.server.tsx - framework-native cache control! diff --git a/app/routes/_main.clients.$slug.tsx b/app/routes/_main.clients.$slug.tsx index aca48c2..fc72f63 100644 --- a/app/routes/_main.clients.$slug.tsx +++ b/app/routes/_main.clients.$slug.tsx @@ -1,4 +1,4 @@ -import { type LoaderFunctionArgs, MetaFunction, data } from 'react-router'; +import { type LoaderFunctionArgs, MetaFunction } from 'react-router'; import { useLoaderData } from 'react-router'; import { css } from '@ocobo/styled-system/css'; @@ -6,37 +6,49 @@ import { css } from '@ocobo/styled-system/css'; import { StoryArticle } from '~/components/stories'; import { Container } from '~/components/ui/Container'; import { ScrollProgressBar } from '~/components/ui/ScrollProgressBar'; -import { createHybridLoader } from '~/modules/cache'; -import { fetchStory } from '~/modules/content'; import { getLang } from '~/utils/lang'; import { getMetaTags } from '~/utils/metatags'; -export const loader = createHybridLoader( - async ({ params }: LoaderFunctionArgs) => { - const { slug } = params; +export async function loader({ params, request }: LoaderFunctionArgs) { + const { slug } = params; - if (!slug) { - throw new Response('Not Found', { status: 404 }); + if (!slug) { + throw new Response('Not Found', { status: 404 }); + } + + const url = new URL(request.url); + const lang = url.searchParams.get('lang') || 'fr'; + + // Build API URL with same origin (internal call) + const apiUrl = new URL(`/api/stories/${slug}`, url.origin); + if (lang !== 'fr') apiUrl.searchParams.set('lang', lang); + + try { + // Call internal API route (optimized on Vercel) + const response = await fetch(apiUrl.toString()); + + if (!response.ok) { + if (response.status === 404) { + throw new Response('Not Found', { status: 404 }); + } + throw new Response('Internal Server Error', { status: 500 }); } - const [status, _state, article] = await fetchStory(slug); + const { data: article, isError } = await response.json(); - if (status !== 200 || !article) { + if (isError || !article) { throw new Response('Not Found', { status: 404 }); } - return data( - { article }, - { - headers: { - 'Cache-Control': 's-maxage=3600, stale-while-revalidate=86400', - Vary: 'Accept-Language', - }, - }, - ); - }, - 'story', // Use story cache strategy -); + return { article }; + } catch (error) { + if (error instanceof Response) { + throw error; + } + console.error('Failed to fetch from API:', error); + throw new Response('Internal Server Error', { status: 500 }); + } +} export const meta: MetaFunction = ({ data, params }) => { return getMetaTags({ diff --git a/app/routes/_main.clients._index.tsx b/app/routes/_main.clients._index.tsx index 308f01f..bd7b1e6 100644 --- a/app/routes/_main.clients._index.tsx +++ b/app/routes/_main.clients._index.tsx @@ -9,22 +9,27 @@ import { ClientCarousel } from '~/components/ClientCarousel'; import { Hero, StoryList } from '~/components/stories'; import { Container } from '~/components/ui/Container'; import { Loader } from '~/components/ui/Loader'; -import { createHybridLoader } from '~/modules/cache'; -import { fetchStories } from '~/modules/content'; -import type { MarkdocFile, StoryFrontmatter } from '~/types'; import { getMetaTags } from '~/utils/metatags'; import { getImageOgFullPath } from '~/utils/url'; -export const loader = createHybridLoader( - async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - const tag = url.searchParams.get('tag'); +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); - const [status, state, storiesData] = await fetchStories(); + // Extract existing parameters + const tag = url.searchParams.get('tag'); + const lang = url.searchParams.get('lang') || 'fr'; - // Handle errors gracefully - if (status !== 200 || !storiesData) { - console.error(`Failed to fetch stories: ${state}`); + // Build API URL with same origin (internal call) + const apiUrl = new URL('/api/stories', url.origin); + if (tag) apiUrl.searchParams.set('tag', tag); + if (lang !== 'fr') apiUrl.searchParams.set('lang', lang); + + try { + // Call internal API route (optimized on Vercel) + const response = await fetch(apiUrl.toString()); + + if (!response.ok) { + console.error(`API call failed: ${response.status}`); return { stories: [], isError: true, @@ -32,29 +37,31 @@ export const loader = createHybridLoader( }; } - const entries = storiesData as MarkdocFile[]; + const { data, isError } = await response.json(); - // Filter and sort stories - const filteredEntries = tag - ? entries.filter((entry) => entry.frontmatter.tags.includes(tag)) - : entries; - - const stories = filteredEntries - .map((entry) => ({ - ...entry, - _sortDate: new Date(entry.frontmatter.date).getTime(), - })) - .sort((a, b) => b._sortDate - a._sortDate) - .map(({ _sortDate, ...entry }) => entry); + if (isError) { + console.error('API returned error'); + return { + stories: [], + isError: true, + ogImageSrc: getImageOgFullPath('clients', request.url), + }; + } return { - stories, + stories: data || [], isError: false, ogImageSrc: getImageOgFullPath('clients', request.url), }; - }, - 'story', // Use story cache strategy -); + } catch (error) { + console.error('Failed to fetch from API:', error); + return { + stories: [], + isError: true, + ogImageSrc: getImageOgFullPath('clients', request.url), + }; + } +} export const meta: MetaFunction = ({ data }) => { if (!data) {