Featured Section - Frontmatter-controlled featured items with featured: true and featuredOrder - Card view with excerpts and list/card toggle button - View preference saved to localStorage - New Convex queries for featured posts and pages with by_featured index Logo Gallery - Continuous marquee scroll with clickable logos - CSS animation, grayscale with color on hover - Configurable speed, position, and title - 5 sample logos included Firecrawl Content Importer - npm run import <url> scrapes external URLs to markdown drafts - Creates local files in content/blog/ with frontmatter - Then sync to dev or prod (no separate import:prod command) API Enhancements - New /api/export endpoint for batch content fetching - AI plugin discovery at /.well-known/ai-plugin.json - OpenAPI 3.0 spec at /openapi.yaml - Enhanced llms.txt documentation Documentation - AGENTS.md with codebase instructions for AI agents - Updated all sync vs deploy tables to include import workflow - Renamed content/pages/changelog.md to changelog-page.md Technical - New components: FeaturedCards.tsx, LogoMarquee.tsx - New script: scripts/import-url.ts - New dependency: @mendable/firecrawl-js - Schema updates with featured, featuredOrder, excerpt fields
11 KiB
AGENTS.md
Instructions for AI coding agents working on this codebase.
Project overview
A real-time markdown blog powered by Convex and React. Content syncs instantly without rebuilds. Write markdown, run a sync command, and posts appear immediately across all connected browsers.
Key features:
- Markdown posts with frontmatter
- Four themes (dark, light, tan, cloud)
- Full text search with Command+K
- Real-time analytics at
/stats - RSS feeds and sitemap for SEO
- API endpoints for AI/LLM access
Tech stack
| Layer | Technology |
|---|---|
| Frontend | React 18, TypeScript, Vite |
| Backend | Convex (real-time serverless database) |
| Styling | CSS variables, no preprocessor |
| Hosting | Netlify with edge functions |
| Content | Markdown with gray-matter frontmatter |
Setup commands
npm install # Install dependencies
npx convex dev # Initialize Convex (creates .env.local)
npm run dev # Start dev server at http://localhost:5173
Content sync commands
npm run sync # Sync markdown to development Convex
npm run sync:prod # Sync markdown to production Convex
npm run import <url> # Import external URL as markdown post
Content syncs instantly. No rebuild needed for markdown changes.
Build and deploy
npm run build # Build for production
npx convex deploy # Deploy Convex functions to production
Netlify build command:
npm ci --include=dev && npx convex deploy --cmd 'npm run build'
Code style guidelines
- Use TypeScript strict mode
- Prefer functional components with hooks
- Use Convex validators for all function arguments and returns
- Always return
v.null()when functions don't return values - Use CSS variables for theming (no hardcoded colors)
- No emoji in UI or documentation
- No em dashes between words
- Sentence case for headings
Convex patterns (read this)
Always use validators
Every Convex function needs argument and return validators:
export const myQuery = query({
args: { slug: v.string() },
returns: v.union(v.object({...}), v.null()),
handler: async (ctx, args) => {
// ...
},
});
Always use indexes
Never use .filter() on queries. Define indexes in schema and use .withIndex():
// Good
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
// Bad - causes table scans
const post = await ctx.db
.query("posts")
.filter((q) => q.eq(q.field("slug"), args.slug))
.first();
Make mutations idempotent
Mutations should be safe to call multiple times:
export const heartbeat = mutation({
args: { sessionId: v.string(), currentPath: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("activeSessions")
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
.first();
if (existing) {
// Early return if recently updated with same data
if (existing.currentPath === args.currentPath &&
now - existing.lastSeen < 10000) {
return null;
}
await ctx.db.patch(existing._id, { currentPath: args.currentPath, lastSeen: now });
return null;
}
await ctx.db.insert("activeSessions", { ...args, lastSeen: now });
return null;
},
});
Patch directly without reading
When you only need to update fields, patch directly:
// Good - patch directly
await ctx.db.patch(args.id, { content: args.content });
// Bad - unnecessary read creates conflict window
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error("Not found");
await ctx.db.patch(args.id, { content: args.content });
Use event records for counters
Never increment counters on documents. Use separate event records:
// Good - insert event record
await ctx.db.insert("pageViews", { path, sessionId, timestamp: Date.now() });
// Bad - counter updates cause write conflicts
await ctx.db.patch(pageId, { views: page.views + 1 });
Frontend debouncing
Debounce rapid mutations from the frontend. Use refs to prevent duplicate calls:
const isHeartbeatPending = useRef(false);
const lastHeartbeatTime = useRef(0);
const sendHeartbeat = useCallback(async (path: string) => {
if (isHeartbeatPending.current) return;
if (Date.now() - lastHeartbeatTime.current < 5000) return;
isHeartbeatPending.current = true;
lastHeartbeatTime.current = Date.now();
try {
await heartbeatMutation({ sessionId, currentPath: path });
} finally {
isHeartbeatPending.current = false;
}
}, [heartbeatMutation]);
Project structure
markdown-blog/
├── content/
│ ├── blog/ # Markdown blog posts
│ └── pages/ # Static pages (About, Docs, etc.)
├── convex/
│ ├── schema.ts # Database schema with indexes
│ ├── posts.ts # Post queries and mutations
│ ├── pages.ts # Page queries and mutations
│ ├── stats.ts # Analytics (conflict-free patterns)
│ ├── search.ts # Full text search
│ ├── http.ts # HTTP endpoints (sitemap, API)
│ ├── rss.ts # RSS feed generation
│ └── crons.ts # Scheduled cleanup jobs
├── netlify/
│ └── edge-functions/ # Proxies for RSS, sitemap, API
├── public/
│ ├── images/ # Static images and logos
│ ├── robots.txt # Crawler rules
│ └── llms.txt # AI agent discovery
├── scripts/
│ └── sync-posts.ts # Markdown to Convex sync
└── src/
├── components/ # React components
├── context/ # Theme context
├── hooks/ # Custom hooks (usePageTracking)
├── pages/ # Route components
└── styles/ # Global CSS with theme variables
Frontmatter fields
Blog posts (content/blog/)
| Field | Required | Description |
|---|---|---|
| title | Yes | Post title |
| description | Yes | SEO description |
| date | Yes | YYYY-MM-DD format |
| slug | Yes | URL path (unique) |
| published | Yes | true to show |
| tags | Yes | Array of strings |
| featured | No | true for featured section |
| featuredOrder | No | Display order (lower first) |
| excerpt | No | Short text for card view |
| image | No | OG image path |
Static pages (content/pages/)
| Field | Required | Description |
|---|---|---|
| title | Yes | Page title |
| slug | Yes | URL path |
| published | Yes | true to show |
| order | No | Nav order (lower first) |
| featured | No | true for featured section |
| featuredOrder | No | Display order (lower first) |
Database schema
Key tables and their indexes:
posts: defineTable({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
// ... optional fields
})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.searchIndex("search_title", { searchField: "title" })
.searchIndex("search_content", { searchField: "content" })
pages: defineTable({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
// ... optional fields
})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
pageViews: defineTable({
path: v.string(),
pageType: v.string(),
sessionId: v.string(),
timestamp: v.number(),
})
.index("by_path", ["path"])
.index("by_timestamp", ["timestamp"])
.index("by_session_path", ["sessionId", "path"])
activeSessions: defineTable({
sessionId: v.string(),
currentPath: v.string(),
lastSeen: v.number(),
})
.index("by_sessionId", ["sessionId"])
.index("by_lastSeen", ["lastSeen"])
HTTP endpoints
| Route | Description |
|---|---|
| /rss.xml | RSS feed with descriptions |
| /rss-full.xml | Full content RSS for LLMs |
| /sitemap.xml | Dynamic XML sitemap |
| /api/posts | JSON list of all posts |
| /api/post?slug=xxx | Single post JSON or markdown |
| /api/export | Batch export all posts with content |
| /stats | Real-time analytics page |
| /.well-known/ai-plugin.json | AI plugin manifest |
| /openapi.yaml | OpenAPI 3.0 specification |
| /llms.txt | AI agent discovery |
Content import
Import external URLs as markdown posts using Firecrawl:
npm run import https://example.com/article
Requires FIRECRAWL_API_KEY in .env.local. Get a key from firecrawl.dev.
Environment files
| File | Purpose |
|---|---|
| .env.local | Development Convex URL (auto-created by npx convex dev) |
| .env.production.local | Production Convex URL (create manually) |
Both are gitignored.
Security considerations
- Escape HTML in all HTTP endpoint outputs using
escapeHtml() - Escape XML in RSS feeds using
escapeXml()or CDATA - Use indexed queries, never scan full tables
- External links must use
rel="noopener noreferrer" - No console statements in production code
- Validate frontmatter before syncing content
Testing
No automated test suite. Manual testing:
- Run
npm run syncafter content changes - Verify content appears at http://localhost:5173
- Check Convex dashboard for function errors
- Test search with Command+K
- Verify stats page updates in real-time
Write conflict prevention
This codebase implements specific patterns to avoid Convex write conflicts:
Backend (convex/stats.ts):
- 10-second dedup window for heartbeats
- Early return when session was recently updated
- Indexed queries for efficient lookups
Frontend (src/hooks/usePageTracking.ts):
- 5-second debounce window using refs
- Pending state tracking prevents overlapping calls
- Path tracking skips redundant heartbeats
See prds/howtoavoidwriteconflicts.md for full details.
Configuration
Site config lives in src/pages/Home.tsx:
const siteConfig = {
name: "Site Name",
title: "Tagline",
logo: "/images/logo.svg", // null to hide
featuredViewMode: "list", // 'list' or 'cards'
showViewToggle: true,
logoGallery: {
enabled: true,
images: [{ src: "/images/logos/logo.svg", href: "https://..." }],
position: "above-footer",
speed: 30,
title: "Trusted by",
},
};
Theme default in src/context/ThemeContext.tsx:
const DEFAULT_THEME: Theme = "tan"; // dark, light, tan, cloud