UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

996 lines (799 loc) 28 kB
--- title: Provider Agnostic Content dimension: knowledge category: provider-agnostic-content.md tags: ai, architecture, backend, frontend related_dimensions: things scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the knowledge dimension in the provider-agnostic-content.md category. Location: one/knowledge/provider-agnostic-content.md Purpose: Documents provider-agnostic content: backend independence Related dimensions: things For AI agents: Read this to understand provider agnostic content. --- # Provider-Agnostic Content: Backend Independence **Layer 4 of Astro Architecture** - Switch content backends with one environment variable ## The Breakthrough Your Astro frontend works with ANY backend—Markdown files, REST API, Convex, WordPress, Supabase, or Notion—without changing a single line of component code. ```typescript // Development: Use Markdown files (no backend needed) const provider = new MarkdownProvider("src/content/teams"); // Production: Use Convex (real-time database) const provider = new ConvexProvider(convexClient); // Production Alternative: Use REST API const provider = new ApiProvider("https://api.example.com"); // Hybrid: Try API first, fall back to Markdown const provider = new HybridProvider( new ApiProvider("https://api.example.com"), new MarkdownProvider("src/content/teams") ); // All use the SAME interface - your pages don't change! const teams = await provider.list(); const team = await provider.get("engineering"); ``` ## The Pattern ``` ┌─────────────────────────────────────────────┐ ASTRO PAGES (never change) await contentProvider.list() await contentProvider.get(id) await contentProvider.create(data) └──────────────────┬──────────────────────────┘ ContentProvider interface ┌──────────┴──────────┬──────────────┬──────────────┐ MarkdownProvider ConvexProvider ApiProvider HybridProvider └─────────┐ src/content/ Convex DB REST API Primary + Fallback (files, no DB) (real-time, auth) (any backend) (best of both) ``` **Key Insight:** Same interface, different implementations. Change backends with ONE environment variable. ## Implementation ### 1. Content Provider Interface (The Contract) **This interface is the key to backend independence.** ```typescript // src/lib/providers/ContentProvider.ts export interface ContentProvider<T = any> { // Read operations list(params?: ListParams): Promise<ContentItem<T>[]>; get(id: string): Promise<ContentItem<T> | null>; // Write operations (optional - not all providers support) create?(data: T): Promise<string>; update?(id: string, data: Partial<T>): Promise<void>; delete?(id: string): Promise<void>; } export interface ContentItem<T = any> { id: string; data: T; // Type-safe data body?: string; // Markdown/HTML content createdAt?: number; updatedAt?: number; } export interface ListParams { limit?: number; offset?: number; filter?: Record<string, any>; sort?: string; } ``` **Every provider must implement this interface. That's the entire contract.** ### 2. Markdown Provider (Development) ```typescript // src/lib/providers/MarkdownProvider.ts import { getCollection } from "astro:content"; import type { ContentProvider, ContentItem } from "./ContentProvider"; export class MarkdownProvider implements ContentProvider { constructor(private collectionName: string) {} async list(): Promise<ContentItem[]> { const items = await getCollection(this.collectionName); return items.map(item => ({ id: item.id, data: item.data, body: item.body, })); } async get(id: string): Promise<ContentItem | null> { const items = await getCollection(this.collectionName); const item = items.find(i => i.id === id); if (!item) return null; const { Content } = await item.render(); return { id: item.id, data: item.data, body: item.body, }; } } ``` ### 3. Convex Provider (Real-Time Database) ```typescript // src/lib/providers/ConvexProvider.ts import type { ConvexClient } from "convex/browser"; import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider"; import { api } from "@/convex/_generated/api"; export class ConvexProvider<T = any> implements ContentProvider<T> { constructor( private client: ConvexClient, private collection: string ) {} async list(params?: ListParams): Promise<ContentItem<T>[]> { const items = await this.client.query(api.queries.content.list, { collection: this.collection, ...params, }); return items.map(item => ({ id: item._id, data: item.data, body: item.body, createdAt: item.createdAt, updatedAt: item.updatedAt, })); } async get(id: string): Promise<ContentItem<T> | null> { const item = await this.client.query(api.queries.content.get, { collection: this.collection, id, }); if (!item) return null; return { id: item._id, data: item.data, body: item.body, createdAt: item.createdAt, updatedAt: item.updatedAt, }; } async create(data: T): Promise<string> { return await this.client.mutation(api.mutations.content.create, { collection: this.collection, data, }); } async update(id: string, data: Partial<T>): Promise<void> { await this.client.mutation(api.mutations.content.update, { collection: this.collection, id, data, }); } async delete(id: string): Promise<void> { await this.client.mutation(api.mutations.content.delete, { collection: this.collection, id, }); } } ``` ### 4. API Provider (REST Backend) ```typescript // src/lib/providers/ApiProvider.ts import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider"; export class ApiProvider<T = any> implements ContentProvider<T> { constructor( private baseUrl: string, private apiKey?: string ) {} private get headers() { return { 'Content-Type': 'application/json', ...(this.apiKey && { 'Authorization': `Bearer ${this.apiKey}` }), }; } async list(params?: ListParams): Promise<ContentItem<T>[]> { const queryString = params ? `?${new URLSearchParams(params as any)}` : ''; const response = await fetch(`${this.baseUrl}/api/content${queryString}`, { headers: this.headers, }); if (!response.ok) throw new Error("Failed to fetch content"); return response.json(); } async get(id: string): Promise<ContentItem<T> | null> { const response = await fetch(`${this.baseUrl}/api/content/${id}`, { headers: this.headers, }); if (response.status === 404) return null; if (!response.ok) throw new Error("Failed to fetch content"); return response.json(); } async create(data: T): Promise<string> { const response = await fetch(`${this.baseUrl}/api/content`, { method: 'POST', headers: this.headers, body: JSON.stringify(data), }); if (!response.ok) throw new Error("Failed to create content"); const result = await response.json(); return result.id; } async update(id: string, data: Partial<T>): Promise<void> { const response = await fetch(`${this.baseUrl}/api/content/${id}`, { method: 'PATCH', headers: this.headers, body: JSON.stringify(data), }); if (!response.ok) throw new Error("Failed to update content"); } async delete(id: string): Promise<void> { const response = await fetch(`${this.baseUrl}/api/content/${id}`, { method: 'DELETE', headers: this.headers, }); if (!response.ok) throw new Error("Failed to delete content"); } } ``` ### 5. Hybrid Provider (Resilience) ```typescript // src/lib/providers/HybridProvider.ts import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider"; /** * Try primary provider first, fall back to secondary on failure. * * Use cases: * - Production API with Markdown fallback (offline support) * - Convex with REST API fallback (redundancy) * - New backend migration (gradual cutover) */ export class HybridProvider<T = any> implements ContentProvider<T> { constructor( private primary: ContentProvider<T>, private fallback: ContentProvider<T> ) {} async list(params?: ListParams): Promise<ContentItem<T>[]> { try { return await this.primary.list(params); } catch (error) { console.warn("Primary provider failed, using fallback", error); return this.fallback.list(params); } } async get(id: string): Promise<ContentItem<T> | null> { try { return await this.primary.get(id); } catch (error) { console.warn("Primary provider failed, using fallback", error); return this.fallback.get(id); } } async create(data: T): Promise<string> { try { if (!this.primary.create) throw new Error("Primary doesn't support create"); return await this.primary.create(data); } catch (error) { if (!this.fallback.create) throw error; console.warn("Primary create failed, using fallback", error); return this.fallback.create(data); } } async update(id: string, data: Partial<T>): Promise<void> { try { if (!this.primary.update) throw new Error("Primary doesn't support update"); await this.primary.update(id, data); } catch (error) { if (!this.fallback.update) throw error; console.warn("Primary update failed, using fallback", error); await this.fallback.update(id, data); } } async delete(id: string): Promise<void> { try { if (!this.primary.delete) throw new Error("Primary doesn't support delete"); await this.primary.delete(id); } catch (error) { if (!this.fallback.delete) throw error; console.warn("Primary delete failed, using fallback", error); await this.fallback.delete(id); } } } ``` ### 6. Factory (Single Configuration Point) **This is where you switch backends—change ONE environment variable.** ```typescript // src/lib/providers/getContentProvider.ts import { ConvexHttpClient } from "convex/browser"; import { MarkdownProvider } from "./MarkdownProvider"; import { ConvexProvider } from "./ConvexProvider"; import { ApiProvider } from "./ApiProvider"; import { HybridProvider } from "./HybridProvider"; import type { ContentProvider } from "./ContentProvider"; export function getContentProvider<T = any>(collection: string): ContentProvider<T> { const mode = import.meta.env.CONTENT_SOURCE || "markdown"; const convexUrl = import.meta.env.PUBLIC_CONVEX_URL; const apiUrl = import.meta.env.PUBLIC_API_URL; const apiKey = import.meta.env.API_KEY; switch (mode) { case "convex": const convexClient = new ConvexHttpClient(convexUrl); return new ConvexProvider<T>(convexClient, collection); case "api": return new ApiProvider<T>(apiUrl, apiKey); case "hybrid": // Try Convex first, fall back to Markdown const primaryClient = new ConvexHttpClient(convexUrl); return new HybridProvider<T>( new ConvexProvider<T>(primaryClient, collection), new MarkdownProvider<T>(collection) ); case "markdown": default: return new MarkdownProvider<T>(collection); } } ``` **That's it. Change `CONTENT_SOURCE` and your entire backend changes. Pages stay identical.** ## Usage in Astro Pages ### Same code for all providers! ```astro --- // src/pages/teams/index.astro import { getContentProvider } from "@/lib/providers/getContentProvider"; import TeamCard from "@/components/TeamCard.tsx"; // Get provider based on config const provider = getContentProvider("teams"); // Query content - works with Markdown OR API const teams = await provider.list(); --- <Layout title="Teams"> <div class="container py-12"> <h1 class="text-4xl font-bold mb-8">Teams</h1> <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> {teams.map(team => ( <TeamCard team={team.data} /> ))} </div> </div> </Layout> ``` ### Detail page ```astro --- // src/pages/teams/[id].astro import { getContentProvider } from "@/lib/providers/getContentProvider"; import TeamDetail from "@/components/TeamDetail.tsx"; const { id } = Astro.params; const provider = getContentProvider("teams"); const team = await provider.get(id!); if (!team) { return Astro.redirect("/teams"); } --- <Layout title={team.data.name}> <TeamDetail team={team.data} /> </Layout> ``` ## Configuration (The Only Thing That Changes) ### Environment Variables ```bash # .env.local - Development (Markdown only, no backend) CONTENT_SOURCE=markdown # .env.production - Production with Convex (real-time) CONTENT_SOURCE=convex PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud # .env.production - Production with REST API CONTENT_SOURCE=api PUBLIC_API_URL=https://api.example.com API_KEY=your-secret-api-key # .env.staging - Staging (tries Convex, falls back to Markdown) CONTENT_SOURCE=hybrid PUBLIC_CONVEX_URL=https://staging.convex.cloud ``` **That's the ONLY configuration change. Your pages, components, and logic remain 100% identical.** ### Or config file ```typescript // src/config/content.ts export const contentConfig = { development: { source: "markdown", }, production: { source: "api", apiUrl: "https://api.example.com", }, staging: { source: "hybrid", apiUrl: "https://staging-api.example.com", fallback: "markdown", }, }; ``` ## Real-World Scenarios ### Scenario 1: Development (No Backend Required) ```bash # .env.local CONTENT_SOURCE=markdown ``` **Result:** - Uses local Markdown/YAML files - No database, no API server needed - Hot reload on file changes - Perfect for prototyping and design **Use when:** Building UI, testing layouts, working offline --- ### Scenario 2: Production with Convex (Real-Time) ```bash # .env.production CONTENT_SOURCE=convex PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud ``` **Result:** - Real-time database with automatic subscriptions - Built-in authentication and file storage - Optimistic updates and offline support - Edge deployment for global speed **Use when:** Need real-time features, auth, full backend --- ### Scenario 3: Production with REST API ```bash # .env.production CONTENT_SOURCE=api PUBLIC_API_URL=https://api.example.com API_KEY=sk_prod_... ``` **Result:** - Works with any REST backend (WordPress, Supabase, custom) - Standard HTTP requests - Flexible authentication - Backend-agnostic **Use when:** Existing API, custom backend, multi-platform support --- ### Scenario 4: Migration Strategy (Zero Downtime) ```bash # .env.production CONTENT_SOURCE=hybrid PUBLIC_CONVEX_URL=https://new-backend.convex.cloud ``` **Result:** - Try new backend (Convex) first - Fall back to Markdown if unavailable - Gradual migration without risk - Content always available **Use when:** Migrating backends, testing new infrastructure --- ### Scenario 5: Offline-First Application ```bash # .env CONTENT_SOURCE=hybrid PUBLIC_API_URL=https://api.example.com ``` **Result:** - Online: Fetch fresh content from API - Offline: Use bundled Markdown files - Seamless switching - No error states for users **Use when:** Mobile apps, unreliable networks, PWAs ## Adding More Providers (Extensibility) **Want to support WordPress, Supabase, Notion, or Airtable? Just implement the interface.** ### Example: Supabase Provider ```typescript // src/lib/providers/SupabaseProvider.ts import { createClient, SupabaseClient } from '@supabase/supabase-js'; import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider"; export class SupabaseProvider<T = any> implements ContentProvider<T> { private supabase: SupabaseClient; constructor(url: string, anonKey: string, private table: string) { this.supabase = createClient(url, anonKey); } async list(params?: ListParams): Promise<ContentItem<T>[]> { let query = this.supabase.from(this.table).select('*'); if (params?.limit) query = query.limit(params.limit); if (params?.offset) query = query.range(params.offset, params.offset + (params.limit || 10)); if (params?.filter) { Object.entries(params.filter).forEach(([key, value]) => { query = query.eq(key, value); }); } const { data, error } = await query; if (error) throw new Error(error.message); return data.map(row => ({ id: row.id, data: row as T, createdAt: new Date(row.created_at).getTime(), updatedAt: new Date(row.updated_at).getTime(), })); } async get(id: string): Promise<ContentItem<T> | null> { const { data, error } = await this.supabase .from(this.table) .select('*') .eq('id', id) .single(); if (error) return null; return { id: data.id, data: data as T, createdAt: new Date(data.created_at).getTime(), updatedAt: new Date(data.updated_at).getTime(), }; } async create(data: T): Promise<string> { const { data: result, error } = await this.supabase .from(this.table) .insert(data) .select() .single(); if (error) throw new Error(error.message); return result.id; } async update(id: string, data: Partial<T>): Promise<void> { const { error } = await this.supabase .from(this.table) .update(data) .eq('id', id); if (error) throw new Error(error.message); } async delete(id: string): Promise<void> { const { error } = await this.supabase .from(this.table) .delete() .eq('id', id); if (error) throw new Error(error.message); } } ``` ### Example: WordPress Provider ```typescript // src/lib/providers/WordPressProvider.ts import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider"; export class WordPressProvider<T = any> implements ContentProvider<T> { constructor( private siteUrl: string, private postType: string = 'posts' ) {} async list(params?: ListParams): Promise<ContentItem<T>[]> { const queryParams = new URLSearchParams({ per_page: String(params?.limit || 10), offset: String(params?.offset || 0), ...(params?.filter || {}) }); const response = await fetch( `${this.siteUrl}/wp-json/wp/v2/${this.postType}?${queryParams}` ); if (!response.ok) throw new Error('Failed to fetch from WordPress'); const posts = await response.json(); return posts.map((post: any) => ({ id: String(post.id), data: { title: post.title.rendered, content: post.content.rendered, excerpt: post.excerpt.rendered, ...post } as T, body: post.content.rendered, createdAt: new Date(post.date).getTime(), updatedAt: new Date(post.modified).getTime(), })); } async get(id: string): Promise<ContentItem<T> | null> { const response = await fetch( `${this.siteUrl}/wp-json/wp/v2/${this.postType}/${id}` ); if (response.status === 404) return null; if (!response.ok) throw new Error('Failed to fetch from WordPress'); const post = await response.json(); return { id: String(post.id), data: { title: post.title.rendered, content: post.content.rendered, excerpt: post.excerpt.rendered, ...post } as T, body: post.content.rendered, createdAt: new Date(post.date).getTime(), updatedAt: new Date(post.modified).getTime(), }; } // WordPress requires authentication for write operations // Implement create/update/delete if you have auth credentials } ``` **Now add to factory:** ```typescript // src/lib/providers/getContentProvider.ts export function getContentProvider<T = any>(collection: string): ContentProvider<T> { const mode = import.meta.env.CONTENT_SOURCE; switch (mode) { case "supabase": return new SupabaseProvider<T>( import.meta.env.PUBLIC_SUPABASE_URL, import.meta.env.PUBLIC_SUPABASE_ANON_KEY, collection ); case "wordpress": return new WordPressProvider<T>( import.meta.env.PUBLIC_WP_SITE_URL, collection ); // ... other providers } } ``` **That's it. Your pages still work identically.** ## Code Generation Even code generation works seamlessly! ```bash # Generate with Markdown provider npx oneie generate:service teams --source=markdown # Generate with API provider npx oneie generate:service teams --source=api # Generate both npx oneie generate:service teams --source=hybrid ``` All generate the same component code, page code, and Effect.ts services. **Only the provider differs.** ## Complete Example: Blog ```astro --- // src/pages/blog/index.astro import { getContentProvider } from "@/lib/providers/getContentProvider"; import BlogCard from "@/components/BlogCard.tsx"; const provider = getContentProvider("blog"); const posts = await provider.list(); // Works with: // - src/content/blog/*.md (Markdown) // - https://api.example.com/api/blog (API) // - Both (Hybrid) // No code changes! --- <Layout> {posts.map(post => <BlogCard post={post.data} />)} </Layout> ``` ## The Magic ```typescript // Developer's perspective: const teams = await provider.list(); // Under the hood: // - Markdown: Read files from disk // - API: HTTP request to backend // - Hybrid: Try API, fall back to Markdown // But all the same code! ``` ## Type Safety Works with full type safety: ```typescript // Content schema in src/content/config.ts const teamsCollection = defineCollection({ schema: z.object({ name: z.string(), members: z.array(z.string()), }), }); // Provider returns typed data const teams = await provider.list(); teams[0].data.name // TypeScript: string teams[0].data.invalid // TypeScript: ERROR! ``` ## Development Workflow ```bash # 1. Start dev with Markdown CONTENT_SOURCE=markdown npm run dev # 2. Create content # echo "name: Engineering" > src/content/teams/eng.yaml # 3. See it live # Browser updates instantly # 4. Build API backend # node backend/server.js # 5. Switch to API # CONTENT_SOURCE=api npm run dev # 6. Same pages work! # No changes needed ``` ## The Philosophy **One interface. Many implementations.** - Markdown for development - API for production - Hybrid for safety - Database for anything - Easy to add more **Switch with one environment variable.** ## Benefits | Before | After | |--------|-------| | Markdown build locally | Markdown OR API, same code | | API build + deploy | Seamless switching | | Migration rewrite | Hybrid support during transition | | Offline not supported | Fallback to Markdown | ## Complete File Structure ``` src/ ├── pages/ # Same code regardless of source ├── teams/index.astro # Works with Markdown OR API ├── teams/[id].astro # Works with Markdown OR API └── blog/index.astro # Works with Markdown OR API ├── content/ # Optional: Markdown fallback ├── config.ts ├── teams/ ├── engineering.yaml └── marketing.yaml └── blog/ └── post-1.md ├── lib/ └── providers/ # Content source abstraction ├── ContentProvider.ts # Interface ├── MarkdownProvider.ts # Markdown implementation ├── ApiProvider.ts # API implementation ├── HybridProvider.ts # Fallback support └── getContentProvider.ts # Factory └── components/ # Same components, any source ├── TeamCard.tsx └── BlogCard.tsx ``` ## Summary: The Power of Abstraction ``` ┌──────────────────────────────────────────────────────┐ ONE ENVIRONMENT VARIABLE CONTENT_SOURCE = markdown|convex|api|hybrid └────────────────────┬─────────────────────────────────┘ ContentProvider Interface ┌──────────────┼──────────────┬──────────────┐ Markdown Convex REST API Supabase Files (Real-time) (Any backend) (PostgreSQL) WordPress Notion Airtable Custom (CMS) (Databases) (Spreadsheets) (DB) All use SAME interface! Your pages NEVER change! ``` ## Key Benefits | Benefit | Description | |---------|-------------| | **Backend Independence** | Switch from Markdown Convex WordPress Supabase with ONE env var | | **Zero Code Changes** | Pages, components, and logic remain 100% identical | | **Development Speed** | Start with Markdown (no backend), deploy with Convex later | | **Migration Safety** | Hybrid mode lets you test new backends without risk | | **Offline Support** | Fallback to bundled Markdown when API unavailable | | **Type Safety** | Generic types ensure data consistency across providers | | **Easy Extension** | Add new providers by implementing one interface | | **Production Ready** | Battle-tested pattern used by enterprise applications | ## The Philosophy **One interface. Many implementations. Zero coupling.** Traditional approach: - Backend logic mixed into components - Different code for each data source - Refactoring required to switch backends - Tight coupling throughout codebase **Provider-agnostic approach:** - Single ContentProvider interface - Same component code for all backends - Change backends with ONE environment variable - Perfect separation of concerns ## What This Enables **For startups:** - Start free with Markdown files (no infrastructure) - Scale to Convex when ready (real-time, auth, storage) - No rewrite required **For agencies:** - Support multiple client backends (WordPress, Supabase, custom) - One codebase, many deployments - Rapid prototyping with Markdown **For enterprises:** - Gradual migrations without downtime - Test new infrastructure safely (Hybrid mode) - Backend redundancy and failover **For developers:** - Work offline with Markdown - Deploy to any backend - Freedom from vendor lock-in --- **This is Layer 4 of the Astro architecture: Provider-agnostic content.** Maximum flexibility. Minimum complexity. True backend independence.