12 KiB
AGENTS.md
Instructions for AI coding agents working on this codebase.
Project overview
Your content is instantly available to browsers, LLMs, and AI agents.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.
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
Current Status
- Site Name: markdown
- Site Title: markdown sync framework
- Site URL: https://markdown.fast
- Total Posts: 14
- Total Pages: 5
- Latest Post: 2025-12-27
- Last Updated: 2025-12-27T23:32:52.395Z
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 |
| authorName | No | Author display name |
| authorImage | No | Round author avatar URL |
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) |
| authorName | No | Author display name |
| authorImage | No | Round author avatar URL |
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/config/siteConfig.ts:
export default {
name: "Site Name",
title: "Tagline",
logo: "/images/logo.svg", // null to hide
blogPage: {
enabled: true, // Enable /blog route
showInNav: true, // Show in navigation
title: "Blog", // Nav link and page title
order: 0, // Nav order (lower = first)
},
displayOnHomepage: true, // Show posts on homepage
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