Files
wiki/AGENTS.md
Wayne Sutton 66a2c4f4d2 feat: add GitHub contributions graph with theme-aware colors
- Add GitHubContributions component with year navigation
- Display contribution activity from github-contributions-api.jogruber.de
- Theme-specific colors for dark, light, tan, and cloud themes
- Phosphor icons for year navigation (CaretLeft, CaretRight)
- Click graph to visit GitHub profile
- Configurable via siteConfig.gitHubContributions
- Mobile responsive with scaled cells and hidden day labels
- Add documentation to setup-guide, docs, README, and changelog
2025-12-20 20:46:34 -08:00

405 lines
11 KiB
Markdown

# AGENTS.md
Instructions for AI coding agents working on this codebase.
## Project overview
An open-source publishing framework for AI agents and developers. 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
## 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
```bash
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
```bash
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
```bash
npm run build # Build for production
npx convex deploy # Deploy Convex functions to production
```
**Netlify build command:**
```bash
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:
```typescript
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()`:
```typescript
// 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:
```typescript
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:
```typescript
// 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:
```typescript
// 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:
```typescript
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:
```typescript
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:
```bash
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:
1. Run `npm run sync` after content changes
2. Verify content appears at http://localhost:5173
3. Check Convex dashboard for function errors
4. Test search with Command+K
5. 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`:
```typescript
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`:
```typescript
const DEFAULT_THEME: Theme = "tan"; // dark, light, tan, cloud
```
## Resources
- [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/)
- [Convex Write Conflicts](https://docs.convex.dev/error#1)
- [Convex TypeScript](https://docs.convex.dev/understanding/best-practices/typescript)
- [Project README](./README.md)
- [Changelog](./changelog.md)
- [Files Reference](./files.md)