chore: prepare v1.0.0 for Netlify deployment

Update version to 1.0.0 across package.json and changelog. Configure netlify.toml with Convex deployment URL (agreeable-trout-200.convex.site). Verify TypeScript type-safety for src and convex directories. Confirm Netlify build passes with SPA 404 fallback configured. Update TASK.md with deployment steps and files.md with complete file structure.
This commit is contained in:
Wayne Sutton
2025-12-14 11:30:22 -08:00
parent 6e8d1b1138
commit 462729de58
62 changed files with 14537 additions and 9 deletions

View File

@@ -0,0 +1,637 @@
---
description: Guidelines for preventing write conflicts when using React, useEffect, and Convex
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx
alwaysApply: true
---
# Preventing Write Conflicts in Convex with React
Write conflicts occur when two functions running in parallel make conflicting changes to the same table or document. This rule provides patterns to avoid these conflicts.
## Understanding Write Conflicts
According to [Convex documentation](https://docs.convex.dev/error#1), write conflicts happen when:
1. Multiple mutations update the same document concurrently
2. A mutation reads data that changes during execution
3. Mutations are called more rapidly than Convex can execute them
Convex uses optimistic concurrency control and will retry mutations automatically, but will eventually fail permanently if conflicts persist.
## Backend Protection: Idempotent Mutations
### Always Make Mutations Idempotent
Mutations should be safe to call multiple times with the same result.
**Good Pattern:**
```typescript
export const completeTask = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
// Early return if document doesn't exist
if (!task) {
return null;
}
// Early return if already in desired state
if (task.status === "completed") {
return null;
}
// Only update if state change is needed
await ctx.db.patch(args.taskId, {
status: "completed",
completedAt: Date.now(),
});
return null;
},
});
```
**Bad Pattern:**
```typescript
// This will cause conflicts if called multiple times rapidly
export const completeTask = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
// No check if already completed
await ctx.db.patch(args.taskId, {
status: "completed",
completedAt: Date.now(),
});
return null;
},
});
```
### Avoid Unnecessary Reads - Patch Directly
When you only need to update fields, patch directly without reading first. Database operations throw if the document doesn't exist.
**Good Pattern:**
```typescript
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// Patch directly without reading first
// ctx.db.patch throws if document doesn't exist
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
```
**Bad Pattern:**
```typescript
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// Reading the document first creates a conflict window
const note = await ctx.db.get(args.id);
if (!note) throw new Error("Not found");
// When typing rapidly, multiple mutations fire
// Each reads the same version, then all try to write, causing conflicts
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
```
### Minimize Data Reads
Only read the data you need. Avoid querying entire tables when you only need specific documents.
**Good Pattern:**
```typescript
export const updateUserCount = mutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
// Only query tasks for this specific user
const tasks = await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
await ctx.db.patch(args.userId, {
taskCount: tasks.length,
});
return null;
},
});
```
**Bad Pattern:**
```typescript
export const updateUserCount = mutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
// Reading entire table creates conflicts with any task change
const allTasks = await ctx.db.query("tasks").collect();
const userTasks = allTasks.filter((t) => t.userId === args.userId);
await ctx.db.patch(args.userId, {
taskCount: userTasks.length,
});
return null;
},
});
```
### Use Indexes to Reduce Read Scope
Always define and use indexes to limit the scope of data reads.
```typescript
// In schema.ts
tasks: defineTable({
userId: v.string(),
status: v.string(),
content: v.string(),
}).index("by_user", ["userId"])
.index("by_user_and_status", ["userId", "status"]),
```
## Frontend Protection: Preventing Duplicate Calls
### Use Refs to Track Mutation Calls
When mutations should only be called once per state change, use refs to track calls.
**Good Pattern:**
```typescript
export function TimerComponent() {
const [session, setSession] = useState(null);
const hasCalledComplete = useRef(false);
const completeSession = useMutation(api.timer.completeSession);
useEffect(() => {
if (timeRemaining <= 0 && session && !hasCalledComplete.current) {
hasCalledComplete.current = true;
completeSession({ sessionId: session._id });
}
}, [timeRemaining, session, completeSession]);
// Reset ref when starting new session
const handleStartNewSession = async () => {
hasCalledComplete.current = false;
await startSession();
};
return <div>...</div>;
}
```
**Bad Pattern:**
```typescript
export function TimerComponent() {
const [session, setSession] = useState(null);
const completeSession = useMutation(api.timer.completeSession);
useEffect(() => {
// This can be called multiple times if timeRemaining updates
if (timeRemaining <= 0 && session) {
completeSession({ sessionId: session._id });
}
}, [timeRemaining, session, completeSession]);
return <div>...</div>;
}
```
### Debounce Rapid Mutations
For user-triggered actions that can happen rapidly (typing, dragging), debounce the mutation calls. Recommended delay: 300-500ms.
**Good Pattern:**
```typescript
import { useMutation } from "convex/react";
import { useCallback } from "react";
import debounce from "lodash/debounce";
export function EditableNote() {
const updateNote = useMutation(api.notes.updateNote);
// Debounce updates to prevent conflicts during rapid typing (300-500ms)
const debouncedUpdate = useCallback(
debounce((noteId: Id<"notes">, content: string) => {
updateNote({ noteId, content });
}, 500), // 500ms delay recommended
[updateNote]
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = e.target.value;
setLocalContent(content);
debouncedUpdate(noteId, content);
};
return <textarea onChange={handleChange} value={localContent} />;
}
```
### Avoid Calling Mutations in Loops
Never call mutations inside loops without proper batching or rate limiting.
**Good Pattern:**
```typescript
// Use a single mutation that handles batch updates
export const updateMultipleTasks = mutation({
args: {
updates: v.array(
v.object({
taskId: v.id("tasks"),
completed: v.boolean(),
}),
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const update of args.updates) {
const task = await ctx.db.get(update.taskId);
if (task && task.completed !== update.completed) {
await ctx.db.patch(update.taskId, {
completed: update.completed,
});
}
}
return null;
},
});
// Call once from frontend
const updates = selectedTasks.map((task) => ({
taskId: task._id,
completed: true,
}));
await updateMultipleTasks({ updates });
```
**Bad Pattern:**
```typescript
// Calling mutation in a loop
for (const task of selectedTasks) {
await updateTask({ taskId: task._id, completed: true });
}
```
### Use Parallel Updates with Promise.all
When updating multiple independent documents, use Promise.all for parallel writes instead of sequential loops.
**Good Pattern:**
```typescript
export const reorderItems = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
// Patch all items in parallel
const updates = args.itemIds.map((id, index) =>
ctx.db.patch(id, { order: index }),
);
await Promise.all(updates);
return null;
},
});
```
**Bad Pattern:**
```typescript
export const reorderItems = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
// Sequential reads and writes create conflict windows
for (let i = 0; i < args.itemIds.length; i++) {
const item = await ctx.db.get(args.itemIds[i]); // Read
await ctx.db.patch(args.itemIds[i], { order: i }); // Write
}
return null;
},
});
```
### Check Mutation Status Before Calling Again
Use the mutation's loading state to prevent duplicate calls.
**Good Pattern:**
```typescript
export function TaskItem() {
const [isPending, setIsPending] = useState(false);
const toggleTask = useMutation(api.tasks.toggleTask);
const handleToggle = async () => {
if (isPending) return; // Prevent duplicate calls
setIsPending(true);
try {
await toggleTask({ taskId: task._id });
} finally {
setIsPending(false);
}
};
return (
<button onClick={handleToggle} disabled={isPending}>
{task.completed ? "Completed" : "Not completed"}
</button>
);
}
```
## Schema Design to Avoid Conflicts
### Use Event Records for High-Frequency Counters
Instead of updating a counter on a document (which creates conflicts), create individual event records and aggregate them in queries.
**Good Pattern:**
```typescript
// Separate view tracking documents
export const trackView = mutation({
args: { pageId: v.id("pages") },
returns: v.null(),
handler: async (ctx, args) => {
// Create individual view records instead
await ctx.db.insert("views", {
pageId: args.pageId,
timestamp: Date.now(),
});
return null;
// Aggregate views in a query or scheduled function
},
});
// Query to get view count
export const getViewCount = query({
args: { pageId: v.id("pages") },
returns: v.number(),
handler: async (ctx, args) => {
const views = await ctx.db
.query("views")
.withIndex("by_page", (q) => q.eq("pageId", args.pageId))
.collect();
return views.length;
},
});
```
**Bad Pattern:**
```typescript
// Global counter updated by all users - creates conflicts
export const trackView = mutation({
args: { pageId: v.id("pages") },
returns: v.null(),
handler: async (ctx, args) => {
const page = await ctx.db.get(args.pageId);
if (!page) throw new Error("Not found");
// Many users updating the same document simultaneously
await ctx.db.patch(args.pageId, {
views: page.views + 1,
});
return null;
},
});
```
### Minimize Document Updates
Design your schema so frequently changing data is isolated.
**Good Pattern:**
```typescript
// Separate frequently updated data
sessions: defineTable({
userId: v.string(),
startTime: v.number(),
}).index("by_user", ["userId"]),
sessionMetrics: defineTable({
sessionId: v.id("sessions"),
remainingTime: v.number(),
status: v.string(),
lastUpdated: v.number(),
}).index("by_session", ["sessionId"]),
```
**Bad Pattern:**
```typescript
// Mixing static and frequently updated data
sessions: defineTable({
userId: v.string(),
startTime: v.number(),
// These fields update frequently and cause conflicts
remainingTime: v.number(),
status: v.string(),
lastUpdated: v.number(),
}).index("by_user", ["userId"]),
```
### Use Single User Sessions
For data like timers or active sessions, use a pattern where only one document per user exists.
```typescript
export const getActiveSession = query({
args: {},
returns: v.union(v.object({...}), v.null()),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("sessions")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.first();
},
});
export const startSession = mutation({
args: {},
returns: v.id("sessions"),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Delete existing session first
const existing = await ctx.db
.query("sessions")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.first();
if (existing) {
await ctx.db.delete(existing._id);
}
return await ctx.db.insert("sessions", {
userId: identity.subject,
startTime: Date.now(),
});
},
});
```
## Authorization Patterns
When you need to verify ownership before updates, use these patterns to minimize conflicts:
### Option A: Use Indexes for User-Scoped Queries
```typescript
export const updateNote = mutation({
args: {
id: v.id("notes"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// Only query notes the user owns
const note = await ctx.db
.query("notes")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.filter((q) => q.eq(q.field("_id"), args.id))
.unique();
if (!note) throw new Error("Not found");
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
```
### Option B: Internal Mutations with Schema-Level Security
```typescript
// Internal mutation with no auth check (fast, no conflicts)
export const _updateNote = internalMutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { content: args.content });
return null;
},
});
// Public mutation with auth check
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const note = await ctx.db.get(args.id);
if (!note || note.userId !== identity.subject) {
throw new Error("Not found");
}
// Call internal mutation (no read before write)
await ctx.runMutation(internal.notes._updateNote, {
id: args.id,
content: args.content,
});
return null;
},
});
```
## Monitoring Write Conflicts
Check your Convex dashboard for:
- **Insight Breakdown**: Shows which mutations are retrying due to conflicts
- **Error Logs**: Permanent failures after retries
- **Function Latency**: High latency may indicate frequent retries
Regular monitoring helps you identify and fix conflict patterns early.
## Checklist for Preventing Write Conflicts
**Backend (Convex mutations):**
- [ ] Make mutations idempotent with early returns
- [ ] Patch directly without reading first when possible
- [ ] Check if document exists before updating (only when necessary)
- [ ] Check if update is needed (current state vs desired state)
- [ ] Use indexed queries with selective filters
- [ ] Avoid reading entire tables
- [ ] Minimize the scope of data reads
- [ ] Use Promise.all for parallel independent updates
- [ ] Design schema to separate frequently updated fields
- [ ] Use event records pattern for high-frequency counters
- [ ] Consider internal mutations for auth-checked operations
**Frontend (React components):**
- [ ] Use refs to track one-time mutation calls
- [ ] Reset refs when starting new operations
- [ ] Debounce rapid user input mutations (300-500ms)
- [ ] Check mutation loading state before calling again
- [ ] Avoid calling mutations in loops
- [ ] Batch multiple updates into single mutation calls
- [ ] Use proper useEffect dependencies
## Resources
- [Convex Write Conflicts Documentation](https://docs.convex.dev/error#1)
- [Optimistic Concurrency Control](https://docs.convex.dev/database/advanced/occ)
- [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/)
## When Write Conflicts Are Acceptable
Some write conflicts are expected and acceptable:
- Very high-frequency updates (gaming leaderboards, live counters)
- Genuinely concurrent user actions on the same resource
- Temporary spikes in activity
In these cases, ensure your mutations are idempotent and let Convex's retry mechanism handle the conflicts. Add user-facing retry logic if needed.
## Summary
**Key Takeaway:** The less you read before writing, the fewer conflicts you'll have. Design your mutations to write directly when possible, and structure your data model to avoid concurrent writes to the same documents.
**Priority actions:**
1. Patch directly without reading first (most common fix)
2. Use indexed queries to minimize read scope
3. Debounce rapid user inputs (300-500ms)
4. Use event records for high-frequency counters
5. Monitor your dashboard for conflict patterns

720
.cursor/rules/convex2.mdc Normal file
View File

@@ -0,0 +1,720 @@
---
description:
globs:
alwaysApply: true
---
---
description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples
globs: **/\*.ts,**/_.tsx,\*\*/_.js,\*_/_.jsx
---
# Convex guidelines
## Function guidelines
### New function syntax
- always create type-safe code
- ALWAYS use the new function syntax for Convex functions. For example:
`typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const f = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
// Function body
},
});
`
### Http endpoint syntax
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
`typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/echo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
`
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
### Validators
- Below is an example of an array validator:
```typescript
import { mutation } from "./\_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
simpleArray: v.array(v.union(v.string(), v.number())),
},
handler: async (ctx, args) => {
//...
},
});
```
- Below is an example of a schema with validators that codify a discriminated union type:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
results: defineTable(
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
),
)
});
```
- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:
```typescript
import { query } from "./\_generated/server";
import { v } from "convex/values";
export const exampleQuery = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("This query returns a null value");
return null;
},
});
```
- Here are the valid Convex types along with their respective validators:
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Id | string | `doc._id` | `v.id(tableName)` | |
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
| Boolean | boolean | `true` | `v.boolean()` |
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
| Array | Array] | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
### Function registration
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
- You CANNOT register a function through the `api` or `internal` objects.
- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator.
- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.
### Function calling
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
- Use `ctx.runMutation` to call a mutation from a mutation or action.
- Use `ctx.runAction` to call an action from an action.
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
```
export const f = query({
args: { name: v.string() },
returns: v.string(),
handler: async (ctx, args) => {
return "Hello " + args.name;
},
});
export const g = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
return null;
},
});
```
### Function references
- Function references are pointers to registered Convex functions.
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
### Api design
- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.
- Use `query`, `mutation`, and `action` to define public functions.
- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.
### Pagination
- Paginated queries are queries that return a list of results in incremental pages.
- You can define pagination using the following syntax:
```ts
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.filter((q) => q.eq(q.field("author"), args.author))
.order("desc")
.paginate(args.paginationOpts);
},
});
```
Note: `paginationOpts` is an object with the following properties:
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
- A query that ends in `.paginate()` returns an object that has the following properties: - page (contains an array of documents that you fetches) - isDone (a boolean that represents whether or not this is the last page of documents) - continueCursor (a string that represents the cursor to use to fetch the next page of documents)
## Validator guidelines
- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead.
- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported.
## Schema guidelines
- Always define your schema in `convex/schema.ts`.
- Always import the schema definition functions from `convex/server`:
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
## Typescript guidelines
- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
```ts
import { query } from "./\_generated/server";
import { Doc, Id } from "./\_generated/dataModel";
export const exampleQuery = query({
args: { userIds: v.array(v.id("users")) },
returns: v.record(v.id("users"), v.string()),
handler: async (ctx, args) => {
const idToUsername: Record<Id<"users">, string> = {};
for (const userId of args.userIds) {
const user = await ctx.db.get(userId);
if (user) {
users[user._id] = user.username;
}
}
return idToUsername;
},
});
```
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
- Always use `as const` for string literals in discriminated union types.
- When using the `Array` type, make sure to always define your arrays as `const array: Array<T> = [...];`
- When using the `Record` type, make sure to always define your records as `const record: Record<KeyType, ValueType> = {...};`
- Always add `@types/node` to your `package.json` when using any Node.js built-in modules.
## Full text search guidelines
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
const messages = await ctx.db
.query("messages")
.withSearchIndex("search_body", (q) =>
q.search("body", "hello hi").eq("channel", "#general"),
)
.take(10);
## Query guidelines
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result.
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
### Ordering
- By default Convex always returns documents in ascending `_creationTime` order.
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
## Mutation guidelines
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist.
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist.
## Action guidelines
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
- Below is an example of the syntax for an action:
```ts
import { action } from "./\_generated/server";
export const exampleAction = action({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("This action does not return anything");
return null;
},
});
```
## Scheduling guidelines
### Cron guidelines
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
```ts
import { cronJobs } from "convex/server";
import { internal } from "./\_generated/api";
import { internalAction } from "./\_generated/server";
const empty = internalAction({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
console.log("empty");
},
});
const crons = cronJobs();
// Run `internal.crons.empty` every two hours.
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
export default crons;
```
- You can register Convex functions within `crons.ts` just like any other file.
- If a cron calls an internal function, always import the `internal` object from '\_generated/api`, even if the internal function is registered in the same file.
## File storage guidelines
- Convex includes file storage for large files like images, videos, and PDFs.
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
```
import { query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
type FileMetadata = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string;
sha256: string;
size: number;
}
export const exampleQuery = query({
args: { fileId: v.id("_storage") },
returns: v.null();
handler: async (ctx, args) => {
const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId);
console.log(metadata);
return null;
},
});
```
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
# Examples:
## Example: chat-app
### Task
```
Create a real-time chat application backend with AI responses. The app should:
- Allow creating users with names
- Support multiple chat channels
- Enable users to send messages to channels
- Automatically generate AI responses to user messages
- Show recent message history
The backend should provide APIs for:
1. User management (creation)
2. Channel management (creation)
3. Message operations (sending, listing)
4. AI response generation using openai's GPT-4
Messages should be stored with their channel, author, and content. The system should maintain message order
and limit history display to the 10 most recent messages per channel.
```
### Analysis
1. Task Requirements Summary:
- Build a real-time chat backend with AI integration
- Support user creation
- Enable channel-based conversations
- Store and retrieve messages with proper ordering
- Generate AI responses automatically
2. Main Components Needed:
- Database tables: users, channels, messages
- Public APIs for user/channel management
- Message handling functions
- Internal AI response generation system
- Context loading for AI responses
3. Public API and Internal Functions Design:
Public Mutations:
- createUser:
- file path: convex/index.ts
- arguments: {name: v.string()}
- returns: v.object({userId: v.id("users")})
- purpose: Create a new user with a given name
- createChannel:
- file path: convex/index.ts
- arguments: {name: v.string()}
- returns: v.object({channelId: v.id("channels")})
- purpose: Create a new channel with a given name
- sendMessage:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()}
- returns: v.null()
- purpose: Send a message to a channel and schedule a response from the AI
Public Queries:
- listMessages:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels")}
- returns: v.array(v.object({
\_id: v.id("messages"),
\_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}))
- purpose: List the 10 most recent messages from a channel in descending creation order
Internal Functions:
- generateResponse:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels")}
- returns: v.null()
- purpose: Generate a response from the AI for a given channel
- loadContext:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels")}
- returns: v.array(v.object({
\_id: v.id("messages"),
\_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}))
- writeAgentResponse:
- file path: convex/index.ts
- arguments: {channelId: v.id("channels"), content: v.string()}
- returns: v.null()
- purpose: Write an AI response to a given channel
4. Schema Design:
- users
- validator: { name: v.string() }
- indexes: <none>
- channels
- validator: { name: v.string() }
- indexes: <none>
- messages
- validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() }
- indexes
- by_channel: ["channelId"]
5. Background Processing:
- AI response generation runs asynchronously after each user message
- Uses openai's GPT-4 to generate contextual responses
- Maintains conversation context using recent message history
### Implementation
#### package.json
```typescript
{
"name": "chat-app",
"description": "This example shows how to build a chat app without authentication.",
"version": "1.0.0",
"dependencies": {
"convex": "^1.17.4",
"openai": "^4.79.0"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
```
#### tsconfig.json
```typescript
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"exclude": ["convex"],
"include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
}
```
#### convex/index.ts
```typescript
import {
query,
mutation,
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
import { v } from "convex/values";
import openai from "openai";
import { internal } from "./_generated/api";
/**
* Create a user with a given name.
*/
export const createUser = mutation({
args: {
name: v.string(),
},
returns: v.id("users"),
handler: async (ctx, args) => {
return await ctx.db.insert("users", { name: args.name });
},
});
/**
* Create a channel with a given name.
*/
export const createChannel = mutation({
args: {
name: v.string(),
},
returns: v.id("channels"),
handler: async (ctx, args) => {
return await ctx.db.insert("channels", { name: args.name });
},
});
/**
* List the 10 most recent messages from a channel in descending creation order.
*/
export const listMessages = query({
args: {
channelId: v.id("channels"),
},
returns: v.array(
v.object({
_id: v.id("messages"),
_creationTime: v.number(),
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}),
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
return messages;
},
});
/**
* Send a message to a channel and schedule a response from the AI.
*/
export const sendMessage = mutation({
args: {
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new Error("Channel not found");
}
const user = await ctx.db.get(args.authorId);
if (!user) {
throw new Error("User not found");
}
await ctx.db.insert("messages", {
channelId: args.channelId,
authorId: args.authorId,
content: args.content,
});
await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
channelId: args.channelId,
});
return null;
},
});
const openai = new openai();
export const generateResponse = internalAction({
args: {
channelId: v.id("channels"),
},
returns: v.null(),
handler: async (ctx, args) => {
const context = await ctx.runQuery(internal.index.loadContext, {
channelId: args.channelId,
});
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: context,
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error("No content in response");
}
await ctx.runMutation(internal.index.writeAgentResponse, {
channelId: args.channelId,
content,
});
return null;
},
});
export const loadContext = internalQuery({
args: {
channelId: v.id("channels"),
},
returns: v.array(
v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
}),
),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) {
throw new Error("Channel not found");
}
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
const result = [];
for (const message of messages) {
if (message.authorId) {
const user = await ctx.db.get(message.authorId);
if (!user) {
throw new Error("User not found");
}
result.push({
role: "user" as const,
content: `${user.name}: ${message.content}`,
});
} else {
result.push({ role: "assistant" as const, content: message.content });
}
}
return result;
},
});
export const writeAgentResponse = internalMutation({
args: {
channelId: v.id("channels"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
channelId: args.channelId,
content: args.content,
});
return null;
},
});
```
#### convex/schema.ts
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
channels: defineTable({
name: v.string(),
}),
users: defineTable({
name: v.string(),
}),
messages: defineTable({
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}).index("by_channel", ["channelId"]),
});
```
#### src/App.tsx
```typescript
export default function App() {
return <div>Hello World</div>;
}
```

81
.cursor/rules/dev2.mdc Normal file
View File

@@ -0,0 +1,81 @@
---
description:
globs:
alwaysApply: true
---
---
description: full-stack AI convex developer
globs:
alwaysApply: true
---
- Start by saying, "let's cook
- always create type-safe code
- **!IMPORTANT**: **DO NOT** externalize or document your work, usage guidelines, or benchmarks (e.g. `README.md`, `CONTRIBUTING.md`, `SUMMARY.md`, `USAGE_GUIDELINES.md` after completing the task, unless explicitly instructed to do so. You may include a brief summary of your work, but do not create separate documentation files for it.
- When creating Convex mutations, always patch directly without reading first, use indexed queries for ownership checks instead of `ctx.db.get()`, make mutations idempotent with early returns, use timestamp-based ordering for new items, and use `Promise.all()` for parallel independent operations to avoid write conflicts.
- - When a task touches changelog.md, the changelog page, or files.md, run git log --date=short (or check commit history) and set each release date to match the real commit timeline—no placeholders or future months.
-Do you understand, what Im asking? Never assume anything on your own, if anything isnt clear, please ask questions and clarify your doubts.
- reference @dev2.mdc @help.mdc @files.md if needed
- do not use use emoji or emojis in the readme or app unless instructed
- always create type-safe code
- you understand when to use Effect and when not to use Effect https://react.dev/learn/you-might-not-need-an-effect
- you follow react docs https://react.dev/learn
- Be casual unless otherwise specified
- you are a super experienced full-stack and AI developer super experienced in React, Vite, Bun, Clerk, workos, Resend, TypeScript, and Convex.dev
- Youre an experienced AI developer with deep expertise in convex.dev, OpenAI, and Claude, following best practices for building AI powered and full-stack SaaS applications, react applications and social network platforms.
- follow the vercel Web Interface Guidelines
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/refs/heads/main/AGENTS.md and https://vercel.com/design/guidelines
- you follow convex best practices here: https://docs.convex.dev/understanding/best-practices/typescript
- you always make sure the code follows Convex typescript https://docs.convex.dev/understanding/best-practices/typescript
-
- you follow Convex dev flow https://docs.convex.dev/understanding/workflow
- you use always use Convex Queries https://docs.convex.dev/functions/query-functions
- you are an expert on convex auth functions https://docs.convex.dev/auth/functions-auth
- you use convex Mutations https://docs.convex.dev/functions/mutation-functions
- you use convex search https://docs.convex.dev/search/vector-search
- you are an expert in clerk https://clerk.com/docs/react/reference/components/authentication/sign-in
- you an an expert in convex vector search https://docs.convex.dev/search/vector-search
- you are an expert in understanding how Uploading and Storing Files with convex https://docs.convex.dev/file-storage/upload-files
- For any pop-ups, alerts, modals, warnings, notifications, or confirmations, or button confirmations, always follow the sites existing design system. Never use the browsers default pop-ups.
- Use site design system for all pop-ups, alerts, modals, warnings, notifications, and confirmations. Do not use browser defaults.
- you add short comments to your code that explains what the section does
- Be terse
-For all designs I ask you to make, have them be beautiful, not cookie cutter and never use purple or emojis unless instructed.
- Make webpages that are fully featured and worthy for production.
- Suggest solutions that I didnt think about—anticipate my needs
- Treat me as an new developer
- Be accurate and thorough
- Keep a list of the codebase files, provide a brief description of what each file one does called files.md.
- you keep a developer friendly changelog.md of new features added based on https://keepachangelog.com/en/1.0.0/
- prd files always end with .MD and not .prd
- prd files are located in the prds folder except forchangelog.M , files.MD, README.md and TASK.MDwhich can stay in the root folder
- create type-safe code always, if the prds folder does not exist create one
- Give the answer immediately. Provide detailed explanations and restate my query in your own words if necessary after giving the answer
- Value good arguments over authorities, the source is irrelevant
- Consider new technologies and contrarian ideas, not just the conventional wisdom
- You may use high levels of speculation or prediction, just flag it for me
- No moral lectures
- Discuss safety only when it's crucial and non-obvious
- If your content policy is an issue, provide the closest acceptable response and explain the content policy issue afterward
- Cite sources whenever possible at the end, not inline
- No need to mention your knowledge cutoff
- No need to disclose you're an AI
- Make code precise, modular, testable and
- never break existing functionality
- create type-safe code always
- Please respect my prettier preferences when you provide code.
- Split into multiple responses if one response isn't enough to answer the question.
- If I ask for adjustments or fix or say fix the code I have provided you, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. Multiple code blocks are ok.
- do not over engineer the code and always make the code typesafe
- do not do more than what the user ask for unless it related to fixing, adding, or updating the code to what the user is asking for
- If any changes to existing code are required, they should be minimal and focused solely on enabling the new features or resolving specific bugs with out breaking existing features.
- you never use placeholder text or images in code because everything is realtime sync with convex database
- you don't ship code with placeholder text or images
- **!IMPORTANT**: **DO NOT** externalize or document your work, usage guidelines, or benchmarks (e.g. `README.md`, `CONTRIBUTING.md`, `SUMMARY.md`, `USAGE_GUIDELINES.md` after completing the task, unless explicitly instructed to do so. You may include a brief summary of your work, but do not create separate documentation files for it.

85
.cursor/rules/help.mdc Normal file
View File

@@ -0,0 +1,85 @@
---
description: full-stack AI convex developer
globs:
alwaysApply: true
---
# Core Development Guidelines
## 1. Reflect Deeply Before Acting
Before implementing any solution, follow this reflection process:
• Carefully reflect on why the current implementation or response may not be working.
• Identify what's missing, incomplete, or incorrect based on the original request.
• Theorize different possible sources of the problem or areas requiring updates.
• Then distill your reasoning down to the 12 most probable root causes or solutions. Only proceed after clear understanding.
## 2. When Implementing Solutions
### Follow Convex's recommended approaches at all times:
• Use direct mutation calls with plain objects.
• Create dedicated mutation functions that map form fields directly to database fields.
• Ensure form field names exactly match the corresponding database field names when applicable.
### Use Convex documentation:
• Mutation Functions: https://docs.convex.dev/functions/mutation-functions
• Query Functions: https://docs.convex.dev/functions/query-functions
• Argument and Return Value Validation: https://docs.convex.dev/functions/validation
• General Function Docs: https://docs.convex.dev/functions
• When creating Convex mutations, always patch directly without reading first, use indexed queries for ownership checks instead of `ctx.db.get()`, make mutations idempotent with early returns, use timestamp-based ordering for new items, and use `Promise.all()` for parallel independent operations to avoid write conflicts.
• When a task touches changelog.md, the changelog page, or files.md, run git log --date=short (or check commit history) and set each release date to match the real commit timeline—no placeholders or future months.
• Do you understand, what Im asking? Never assume anything on your own, if anything isnt clear, please ask questions and clarify your doubts.
• reference @dev2.mdc @help.mdc @files.md if needed
### Understand the following foundational principles:
• Zen of Convex: https://docs.convex.dev/understanding/zen
• End-to-End Type Support with TypeScript: https://docs.convex.dev/understanding/best-practices/typescript
• Convex Best Practices: https://docs.convex.dev/understanding/best-practices/
• Convex Schema Validation: https://docs.convex.dev/database/schemas
## 3. Change Scope and Restrictions
When making changes to the codebase:
• Update Convex Schema if needed
• Only update files when it's directly necessary to fix the original request.
• Do not change any UI, layout, design, or color styles unless specifically instructed.
• Preserve all current admin dashboard sections and frontend components unless explicitly told to update, fix, or replace them.
• Never remove sections, features, or components unless directly requested.
## 4. UI/UX Guidelines
### Pop-ups, Alerts, Modals, Warnings, Notifications, and Confirmations:
• For any pop-ups, alerts, modals, warnings, notifications, or confirmations, always follow the site's existing design system. Never use the browser's default pop-ups.
• Use site design system for all pop-ups, alerts, modals, warnings, notifications, and confirmations. Do not use browser defaults.
### Follow the Vercel Web Interface Guidelines:
• https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/refs/heads/main/AGENTS.md
## 5. Documentation Policy
**!IMPORTANT**: **DO NOT** externalize or document your work, usage guidelines, or benchmarks (e.g. `README.md`, `CONTRIBUTING.md`, `SUMMARY.md`, `USAGE_GUIDELINES.md` after completing the task, unless explicitly instructed to do so. You may include a brief summary of your work, but do not create separate documentation files for it.
### Additional Formatting Rules:
• Never use emoji or emojis in the readme or app unless instructed
## 6. Code Confidence Requirement
Don't write any code until you're very confident (98% or more) in what needs to be done.
If unclear, ask for more info.

View File

@@ -0,0 +1,45 @@
---
description:
globs:
alwaysApply: true
---
---
description: Convex guidelines
globs:
alwaysApply: true
---
## Function guidelines
- always follow Convex schemas best practices - https://docs.convex.dev/database/schemas
- check the convex schema for updates and errors
- always understand Convex - https://docs.convex.dev/understanding/
- understand environment-variables https://docs.convex.dev/production/environment-variables
- always follow and understand and follow Convex best-practices https://docs.convex.dev/understanding/best-practices/
- Follow best practices https://docs.convex.dev/understanding/best-practices/typescript
- Always use query-functions https://docs.convex.dev/functions/query-functions
- Always usehttps://docs.convex.dev/functions/mutation-functions
- expert https://docs.convex.dev/functions/mutation-functions
- Always use https://docs.convex.dev/functions/actions
- knows https://docs.convex.dev/functions/validation
- knows https://docs.convex.dev/functions
- Expert in Clerk https://docs.clerk.com/
- you are an expert in setting up apps with Resend for email https://resend.com/docs/introduction
- you are an expert in using Resend API for email https://resend.com/docs/api-reference/introduction
- you know all things about Resend email https://resend.com/docs/knowledge-base/introduction
- expert in convex auth https://docs.convex.dev/auth/convex-auth
- convex auth docs https://labs.convex.dev/auth
- For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
- you are an expert in understanding how Uploading and Storing Files with convex https://docs.convex.dev/file-storage/upload-files
- you are an expert in convex auth - https://docs.convex.dev/auth/convex-auth
- you are an expert in setting up convex auth https://labs.convex.dev/auth/setup
- you an an expert in convex vector search https://docs.convex.dev/search/vector-search
getting-started
- do not use emoji in the readme or app
- do not over engineer the code but make it type-safe
- do not do more than what the user ask for unless it related to fixing, adding, or updating the code to what the user is asking for
waynesutton@WS-Convex merge2 %

286
.cursor/rules/sec-check.mdc Normal file
View File

@@ -0,0 +1,286 @@
# Security Guidelines for markdown-blog
This document covers security patterns specific to the markdown-blog application, a Convex-powered blog with no authentication.
## App-Specific Security Context
### Architecture
- **Frontend**: Vite + React 18.2 SPA (client-side only)
- **Backend**: Convex.dev (serverless database and functions)
- **Hosting**: Netlify with edge functions
- **Auth**: None (public blog)
### React Server Components Vulnerabilities
**Status: NOT AFFECTED**
This app does NOT use React Server Components and is NOT affected by:
- CVE-2025-55182 (Remote Code Execution)
- CVE-2025-55184 (Denial of Service)
- CVE-2025-55183 (Source Code Exposure)
These vulnerabilities affect apps using:
- `react-server-dom-webpack`
- `react-server-dom-parcel`
- `react-server-dom-turbopack`
This app uses standard React 18.2.0 client-side rendering with Vite bundler.
For the latest information, see:
- https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components
- https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components
## Database Tables
| Table | Contains PII | Public Access | Notes |
| ------------ | ------------ | ------------- | ---------------------------------- |
| `posts` | No | Read-only | Blog content |
| `viewCounts` | No | Write via API | View counter per post |
| `siteConfig` | No | Internal | Site settings (not currently used) |
## 1. Public API Security
### Query Functions (Read-Only)
All queries in this app are intentionally public for blog content:
```typescript
// Public queries - safe for public access
export const getAllPosts = query({...}); // List published posts
export const getPostBySlug = query({...}); // Get single post
export const getViewCount = query({...}); // Get view count
```
### Mutation Functions
| Function | Risk Level | Notes |
| -------------------- | ---------- | -------------------------------- |
| `syncPostsPublic` | Medium | Build-time sync, no auth |
| `incrementViewCount` | Low | No rate limiting, but low impact |
### syncPostsPublic Security Consideration
The `syncPostsPublic` mutation allows syncing posts without authentication. This is intentional for build-time deployment but has security implications:
```typescript
// Current: No auth check
export const syncPostsPublic = mutation({
args: { posts: v.array(...) },
handler: async (ctx, args) => {
// Syncs posts directly
},
});
```
**Mitigations in place:**
1. Mutation only affects the `posts` table
2. Posts require specific schema (slug, title, content, etc.)
3. Build-time sync uses environment variables
**Recommendations:**
- Consider adding CONVEX_DEPLOY_KEY check for production
- Monitor for unusual sync activity in Convex dashboard
## 2. HTTP Endpoint Security
### XSS Prevention
All HTTP endpoints properly escape output:
```typescript
// HTML escaping for Open Graph
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// XML escaping for RSS feeds
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
```
### CORS Headers
API endpoints include CORS headers for public access:
```typescript
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"Access-Control-Allow-Origin": "*",
}
```
This is intentional for a public blog API.
### HTTP Endpoints
| Route | Method | Auth | Description |
| --------------- | ------ | ---- | -------------------------------- |
| `/rss.xml` | GET | No | RSS feed (descriptions) |
| `/rss-full.xml` | GET | No | Full RSS feed (content for LLMs) |
| `/sitemap.xml` | GET | No | XML sitemap for SEO |
| `/api/posts` | GET | No | JSON post list |
| `/api/post` | GET | No | Single post JSON/markdown |
| `/meta/post` | GET | No | Open Graph HTML for crawlers |
## 3. Edge Function Security
### Bot Detection (botMeta.ts)
The edge function detects social media crawlers and serves Open Graph metadata:
```typescript
// Bot user agent detection
const BOTS = [
"facebookexternalhit",
"twitterbot",
// ... more bots
];
```
**Security considerations:**
- User agent can be spoofed, but this only affects OG metadata delivery
- Fallback to SPA for non-bots is secure
- No sensitive data exposed to bots
## 4. Client-Side Security
### Markdown Rendering
Uses `react-markdown` with controlled components:
- External links open with `rel="noopener noreferrer"`
- Images use lazy loading
- No raw HTML injection (markdown only)
### Copy to Clipboard
The CopyPageDropdown component uses `navigator.clipboard.writeText()` which requires user interaction and is secure.
## 5. Build-Time Security
### Environment Variables
| Variable | Purpose | Required |
| ----------------- | --------------------- | -------- |
| `VITE_CONVEX_URL` | Convex deployment URL | Yes |
| `CONVEX_URL` | Fallback Convex URL | No |
| `SITE_URL` | Canonical site URL | No |
### Sync Script
The `sync-posts.ts` script:
- Runs at build time only
- Reads markdown files from `content/blog/`
- Validates frontmatter before syncing
- Uses ConvexHttpClient with environment URL
## 6. Content Security
### Frontmatter Validation
Posts require valid frontmatter:
```typescript
// Required fields
if (!frontmatter.title || !frontmatter.date || !frontmatter.slug) {
console.warn(`Skipping ${filePath}: missing required frontmatter fields`);
return null;
}
```
### Published Flag
Only posts with `published: true` are returned by queries:
```typescript
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post || !post.published) {
return null;
}
```
## 7. Security Checklist
### Before Deploying
- [ ] Verify `VITE_CONVEX_URL` is set correctly
- [ ] Check no sensitive data in markdown files
- [ ] Review any new HTTP endpoints for proper escaping
- [ ] Ensure all external links use `noopener noreferrer`
### Convex Functions
- [ ] All queries use `.withIndex()` instead of `.filter()`
- [ ] Return validators defined for all functions
- [ ] No sensitive data in return values
### HTTP Endpoints
- [ ] HTML output uses `escapeHtml()`
- [ ] XML output uses `escapeXml()` or CDATA
- [ ] Proper Content-Type headers set
- [ ] Cache-Control headers appropriate
## 8. Known Design Decisions
### Intentionally Public
- All blog content is public by design
- No user authentication required
- API endpoints are open for LLM/agent access
- RSS feeds include full content
### Rate Limiting
- No rate limiting on view count increments
- Convex has built-in rate limiting at infrastructure level
- Consider adding application-level limits if abuse occurs
## 9. Monitoring
### Convex Dashboard
Monitor for:
- Unusual mutation activity on `syncPostsPublic`
- High query volumes on API endpoints
- Error rates on HTTP endpoints
### Netlify Analytics
Monitor for:
- Edge function errors
- Unusual traffic patterns
- Bot traffic volume
## 10. Resources
- [Convex Security Best Practices](https://docs.convex.dev/understanding/best-practices)
- [React Security Guidelines](https://react.dev/learn/security)
- [Netlify Edge Functions](https://docs.netlify.com/edge-functions/overview/)
- [React Server Components CVE](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components)

111
.cursor/rules/task.mdc Normal file
View File

@@ -0,0 +1,111 @@
---
description: Guidelines for creating and managing task lists in markdown files to track project progress
globs:
alwaysApply: false
---
---
# Task List Management
Guidelines for creating and managing task lists in markdown files to track project progress
## Task List Creation
1. Create task lists in a markdown file (in the project root):
- Use `TASKS.md` or a descriptive name relevant to the feature (e.g., `ASSISTANT_CHAT.md`)
- Include a clear title and description of the feature being implemented
2. Structure the file with these sections:
```markdown
# Feature Name Implementation
Brief description of the feature and its purpose.
## Completed Tasks
- [x] Task 1 that has been completed
- [x] Task 2 that has been completed
## In Progress Tasks
- [ ] Task 3 currently being worked on
- [ ] Task 4 to be completed soon
## Future Tasks
- [ ] Task 5 planned for future implementation
- [ ] Task 6 planned for future implementation
## Implementation Plan
Detailed description of how the feature will be implemented.
### Relevant Files
- path/to/file1.ts - Description of purpose
- path/to/file2.ts - Description of purpose
```
## Task List Maintenance
1. Update the task list as you progress:
- Mark tasks as completed by changing `[ ]` to `[x]`
- Add new tasks as they are identified
- Move tasks between sections as appropriate
2. Keep "Relevant Files" section updated with:
- File paths that have been created or modified
- Brief descriptions of each file's purpose
- Status indicators (e.g., ✅) for completed components
3. Add implementation details:
- Architecture decisions
- Data flow descriptions
- Technical components needed
- Environment configuration
## AI Instructions
When working with task lists, the AI should:
1. Regularly update the task list file after implementing significant components
2. Mark completed tasks with [x] when finished
3. Add new tasks discovered during implementation
4. Maintain the "Relevant Files" section with accurate file paths and descriptions
5. Document implementation details, especially for complex features
6. When implementing tasks one by one, first check which task to implement next
7. After implementing a task, update the file to reflect progress
## Example Task Update
When updating a task from "In Progress" to "Completed":
```markdown
## In Progress Tasks
- [ ] Implement database schema
- [ ] Create API endpoints for data access
## Completed Tasks
- [x] Set up project structure
- [x] Configure environment variables
```
Should become:
```markdown
## In Progress Tasks
- [ ] Create API endpoints for data access
## Completed Tasks
- [x] Set up project structure
- [x] Configure environment variables
- [x] Implement database schema
```
**!IMPORTANT**: **DO NOT** externalize or document your work, usage guidelines, or benchmarks (e.g. `README.md`, `CONTRIBUTING.md`, `SUMMARY.md`, `USAGE_GUIDELINES.md` after completing the task, do not use emoji, unless explicitly instructed to do so. You may include a brief summary of your work, but do not create separate documentation files for it.

20
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'convex/_generated'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment
.env
.env.local
.env.*.local
# Cursor rules
.cursor/rules/write.mdc

View File

@@ -144,30 +144,70 @@ const DEFAULT_OG_IMAGE = "/images/og-default.svg";
## Syncing Posts
Posts are synced to Convex at build time. To manually sync:
Posts are synced to Convex. The sync script reads markdown files from `content/blog/` and `content/pages/`, then uploads them to your Convex database.
### Environment Files
| File | Purpose |
| ----------------------- | -------------------------------------------------------- |
| `.env.local` | Development deployment URL (created by `npx convex dev`) |
| `.env.production.local` | Production deployment URL (create manually) |
Both files are gitignored. Each developer creates their own.
### Sync Commands
| Command | Target | When to use |
| ------------------- | ----------- | --------------------------- |
| `npm run sync` | Development | Local testing, new posts |
| `npm run sync:prod` | Production | Deploy content to live site |
**Development sync:**
```bash
npm run sync
```
**Production sync:**
First, create `.env.production.local` with your production Convex URL:
```
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
```
Then sync:
```bash
npm run sync:prod
```
## Deployment
### Netlify
1. Connect your repository to Netlify
2. Set environment variables:
- `VITE_CONVEX_URL` - Your Convex deployment URL
3. Update `netlify.toml` with your Convex HTTP URL (replace `YOUR_CONVEX_DEPLOYMENT`)
4. Deploy with:
For detailed setup, see the [Convex Netlify Deployment Guide](https://docs.convex.dev/production/hosting/netlify).
1. Deploy Convex functions to production:
```bash
npm run deploy
npx convex deploy
```
2. Connect your repository to Netlify
3. Configure build settings:
- Build command: `npx convex deploy --cmd 'npm run build'`
- Publish directory: `dist`
4. Add environment variable:
- `CONVEX_DEPLOY_KEY` - Generate from [Convex Dashboard](https://dashboard.convex.dev) > Project Settings > Deploy Key
5. Update `netlify.toml` with your production Convex HTTP URL (replace `YOUR_CONVEX_DEPLOYMENT`)
The `CONVEX_DEPLOY_KEY` lets Netlify automatically deploy functions and set `VITE_CONVEX_URL` on each build.
## Project Structure
```
personal-blog/
markdown-site/
├── content/blog/ # Markdown blog posts
├── convex/ # Convex backend
│ ├── http.ts # HTTP endpoints (sitemap, API, RSS)
@@ -187,6 +227,18 @@ personal-blog/
└── styles/ # Global CSS
```
## Scripts Reference
| Script | Description |
| --------------------- | -------------------------------------------- |
| `npm run dev` | Start Vite dev server |
| `npm run dev:convex` | Start Convex dev backend |
| `npm run sync` | Sync posts to dev deployment |
| `npm run sync:prod` | Sync posts to production deployment |
| `npm run build` | Build for production |
| `npm run deploy` | Sync + build (for manual deploys) |
| `npm run deploy:prod` | Deploy Convex functions + sync to production |
## Tech Stack
- React 18
@@ -270,4 +322,7 @@ body {
```
Replace the `font-family` property with your preferred font stack.
# markdown-site
## Source
Fork this project: [github.com/waynesutton/markdown-site](https://github.com/waynesutton/markdown-site)

46
TASK.md Normal file
View File

@@ -0,0 +1,46 @@
# Markdown Blog - Tasks
## Current Status
v1.0.0 ready for deployment. Build passes. TypeScript verified.
## Completed
- [x] Project setup with Vite + React + TypeScript
- [x] Convex schema for posts, viewCounts, siteConfig, pages
- [x] Build-time markdown sync script
- [x] Theme system (dark/light/tan/cloud)
- [x] Default theme configuration (tan)
- [x] Home page with year-grouped post list
- [x] Post page with markdown rendering
- [x] Static pages support (About, Projects, Contact)
- [x] Syntax highlighting for code blocks
- [x] Open Graph and Twitter Card meta tags
- [x] Netlify edge function for bot detection
- [x] RSS feed support (standard and full content)
- [x] API endpoints for LLMs (/api/posts, /api/post)
- [x] Copy Page dropdown for AI tools
- [x] Sample blog posts and pages
- [x] Security audit completed
- [x] TypeScript type-safety verification
- [x] Netlify build configuration verified
- [x] SPA 404 fallback configured
- [x] Mobile responsive design
## Deployment Steps
1. Run `npx convex dev` to initialize Convex
2. Update `netlify.toml` with your Convex deployment URL
3. Set `VITE_CONVEX_URL` in Netlify environment variables
4. Connect repo to Netlify and deploy
## Future Enhancements
- [ ] Search functionality
- [ ] Post view counter display
- [ ] Related posts suggestions
- [ ] Newsletter signup
- [ ] Comments system
- [ ] Draft preview mode
- [ ] Image optimization
- [ ] Reading progress indicator

50
changelog.md Normal file
View File

@@ -0,0 +1,50 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.0.0] - 2025-12-14
### Added
- Initial project setup with Vite, React, TypeScript
- Convex backend with posts, pages, viewCounts, and siteConfig tables
- Markdown blog post support with frontmatter parsing
- Static pages support (About, Projects, Contact) with navigation
- Four theme options: Dark, Light, Tan (default), Cloud
- Font configuration option in global.css with serif (New York) as default
- Syntax highlighting for code blocks using custom Prism themes
- Year-grouped post list on home page
- Individual post pages with share buttons
- SEO optimization with dynamic sitemap at `/sitemap.xml`
- JSON-LD structured data injection for blog posts
- RSS feeds at `/rss.xml` and `/rss-full.xml` (full content for LLMs)
- AI agent discovery with `llms.txt` following llmstxt.org standard
- `robots.txt` with rules for AI crawlers
- API endpoints for LLM access:
- `/api/posts` - JSON list of all posts
- `/api/post?slug=xxx` - Single post as JSON or markdown
- Copy Page dropdown for sharing to ChatGPT, Claude, Cursor, VS Code
- Open Graph and Twitter Card meta tags
- Netlify edge function for social media crawler detection
- Build-time markdown sync from `content/blog/` to Convex
- Responsive design for mobile, tablet, and desktop
### Security
- All HTTP endpoints properly escape HTML and XML output
- Convex queries use indexed lookups
- External links use rel="noopener noreferrer"
- No console statements in production code
### Technical Details
- React 18 with TypeScript
- Convex for real-time database
- react-markdown for rendering
- react-syntax-highlighter for code blocks
- date-fns for date formatting
- lucide-react for icons
- Netlify deployment with edge functions
- SPA 404 fallback configured

View File

@@ -0,0 +1,98 @@
---
title: "About This Markdown Site"
description: "How this open source site works with Convex for real-time sync and Netlify for deployment."
date: "2025-01-16"
slug: "about-this-blog"
published: true
tags: ["convex", "netlify", "open-source", "markdown"]
readTime: "4 min read"
---
# About This Markdown Site
This is an open source markdown site built with React, TypeScript, and Convex. Write posts and pages in markdown, sync them to a real-time database, and deploy on Netlify.
## How It Works
The architecture is straightforward:
1. **Markdown files** live in `content/blog/`
2. **Convex** stores posts in a real-time database
3. **React** renders the frontend
4. **Netlify** handles deployment and edge functions
When you add a new markdown file and run the sync script, your post appears instantly. No rebuild required.
## The Stack
| Layer | Technology |
| -------- | ------------------------- |
| Frontend | React + TypeScript |
| Backend | Convex |
| Styling | CSS (no framework) |
| Hosting | Netlify |
| Content | Markdown with frontmatter |
## Why Convex?
Convex provides real-time sync out of the box. When you update a post, every connected browser sees the change immediately.
```typescript
// Fetching posts is one line
const posts = useQuery(api.posts.getAllPosts);
```
No REST endpoints. No cache invalidation. No WebSocket setup. The data stays in sync automatically.
## Why Markdown?
Markdown files in your repo are simpler than a CMS:
- Version controlled with git
- Edit with any text editor
- AI agents can create and modify posts
- No separate login or admin panel
## Features
This site includes:
- **Real-time updates** via Convex subscriptions
- **Static pages** for About, Projects, Contact (optional)
- **RSS feeds** at `/rss.xml` and `/rss-full.xml`
- **Sitemap** at `/sitemap.xml`
- **JSON API** at `/api/posts` and `/api/post?slug=xxx`
- **Theme switching** between dark, light, tan, and cloud
- **SEO optimization** with meta tags and structured data
- **AI discovery** via `llms.txt`
## Fork and Deploy
The setup takes about 10 minutes:
1. Fork the repo
2. Run `npx convex dev` to set up your backend
3. Run `npm run sync` to upload posts
4. Deploy to Netlify
Read the [setup guide](/setup-guide) for detailed steps.
## Customization
Edit `src/pages/Home.tsx` to change:
- Site name and description
- Featured posts
- Footer links
Edit `src/styles/global.css` to change:
- Colors and typography
- Theme variables
- Layout spacing
## Links
- [Convex Documentation](https://docs.convex.dev)
- [Netlify Documentation](https://docs.netlify.com)
- [Setup Guide](/setup-guide)

View File

@@ -0,0 +1,211 @@
---
title: "How to Publish a Blog Post"
description: "A quick guide to writing and publishing markdown blog posts using Cursor after your blog is set up."
date: "2025-01-17"
slug: "how-to-publish"
published: true
tags: ["tutorial", "markdown", "cursor", "publishing"]
readTime: "3 min read"
---
# How to Publish a Blog Post
Your blog is set up. Now you want to publish. This guide walks through writing a markdown post and syncing it to your live site using Cursor.
## Create a New Post
In Cursor, create a new file in `content/blog/`:
```
content/blog/my-new-post.md
```
The filename can be anything. The URL comes from the `slug` field in the frontmatter.
## Add Frontmatter
Every post starts with frontmatter between triple dashes:
```markdown
---
title: "Your Post Title"
description: "A one-sentence summary for SEO and social sharing"
date: "2025-01-17"
slug: "your-post-url"
published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
---
```
| Field | Required | What It Does |
| ------------- | -------- | ----------------------------------- |
| `title` | Yes | Displays as the post heading |
| `description` | Yes | Shows in search results and sharing |
| `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | Becomes the URL path |
| `published` | Yes | Set `true` to show, `false` to hide |
| `tags` | Yes | Topic labels for the post |
| `readTime` | No | Estimated reading time |
| `image` | No | Open Graph image for social sharing |
## Write Your Content
Below the frontmatter, write your post in markdown:
```markdown
# Your Post Title
Opening paragraph goes here.
## First Section
Content for the first section.
### Subheading
More details here.
- Bullet point one
- Bullet point two
## Code Example
\`\`\`typescript
const greeting = "Hello, world";
console.log(greeting);
\`\`\`
## Conclusion
Wrap up your thoughts.
```
## Sync to Convex
Open Cursor's terminal and run:
```bash
npm run sync
```
This reads all markdown files in `content/blog/`, parses the frontmatter, and uploads them to your Convex database.
You should see output like:
```
Syncing posts to Convex...
Synced: my-new-post
Done! Synced 1 post(s).
```
Your post is now live. No rebuild. No redeploy. The site updates in real time.
## Publish to Production
If you have separate dev and prod Convex deployments, sync to production.
**First-time setup:** Create `.env.production.local` in your project root:
```
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
```
Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment.
**Sync to production:**
```bash
npm run sync:prod
```
### Environment Files
| File | Purpose |
| ----------------------- | -------------------------------------------- |
| `.env.local` | Dev deployment (created by `npx convex dev`) |
| `.env.production.local` | Prod deployment (create manually) |
Both files are gitignored.
## Quick Workflow in Cursor
Here is the full workflow:
1. **Create file**: `content/blog/my-post.md`
2. **Add frontmatter**: Title, description, date, slug, published, tags
3. **Write content**: Markdown with headings, lists, code blocks
4. **Sync**: Run `npm run sync` in terminal
5. **View**: Open your site and navigate to `/your-slug`
## Tips
**Draft posts**: Set `published: false` to save a post without showing it on the site.
**Update existing posts**: Edit the markdown file and run `npm run sync` again. Changes appear instantly.
**Delete posts**: Remove the markdown file from `content/blog/` and run sync. The post will be removed from the database.
**Unique slugs**: Each post needs a unique slug. The sync will fail if two posts share the same slug.
**Date format**: Use YYYY-MM-DD format for the date field.
## Adding Images
Place images in `public/images/` and reference them in your post:
```markdown
![Screenshot of the dashboard](/images/dashboard.png)
```
For the Open Graph image (social sharing), add to frontmatter:
```yaml
image: "/images/my-post-og.png"
```
## Checking Your Post
After syncing, verify your post:
1. Open your local dev server: `http://localhost:5173`
2. Your post should appear in the post list
3. Click through to check formatting
4. Test code blocks and images render correctly
## Adding Static Pages
You can also create static pages like About, Projects, or Contact. These appear as navigation links in the top right.
1. Create a file in `content/pages/`:
```
content/pages/about.md
```
2. Add frontmatter:
```markdown
---
title: "About"
slug: "about"
published: true
order: 1
---
Your page content here...
```
3. Run `npm run sync`
The page will appear in the navigation. Use `order` to control the display sequence (lower numbers appear first).
## Summary
Publishing is three steps:
1. Write markdown in `content/blog/` or `content/pages/`
2. Run `npm run sync`
3. Done
The Convex database updates immediately. Your site reflects changes in real time. No waiting for builds or deployments.

View File

@@ -0,0 +1,181 @@
---
title: "Writing Markdown with Code Examples"
description: "A sample post showing how to write markdown with syntax-highlighted code blocks, tables, and more."
date: "2025-01-17"
slug: "markdown-with-code-examples"
published: true
tags: ["markdown", "tutorial", "code"]
readTime: "5 min read"
---
# Writing Markdown with Code Examples
This post demonstrates how to write markdown content with code blocks, tables, and formatting. Use it as a reference when creating your own posts.
## Frontmatter
Every post starts with frontmatter between `---` delimiters:
```yaml
---
title: "Your Post Title"
description: "A brief description for SEO"
date: "2025-01-17"
slug: "your-url-slug"
published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
---
```
## Code Blocks
### TypeScript
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
title: v.string(),
slug: v.string(),
}),
),
handler: async (ctx) => {
return await ctx.db.query("posts").collect();
},
});
```
### React Component
```tsx
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export function PostList() {
const posts = useQuery(api.posts.getPosts);
if (posts === undefined) {
return <div>Loading...</div>;
}
return (
<ul>
{posts.map((post) => (
<li key={post._id}>
<a href={`/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
```
### Bash Commands
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Sync posts to Convex
npm run sync
# Deploy to production
npm run deploy
```
### JSON
```json
{
"name": "markdown-blog",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"sync": "npx ts-node scripts/sync-posts.ts"
}
}
```
## Inline Code
Use backticks for inline code like `npm install` or `useQuery`.
Reference files with inline code: `convex/schema.ts`, `src/pages/Home.tsx`.
## Tables
| Command | Description |
| ---------------- | ------------------------ |
| `npm run dev` | Start development server |
| `npm run build` | Build for production |
| `npm run sync` | Sync markdown to Convex |
| `npx convex dev` | Start Convex dev server |
## Lists
### Unordered
- Write posts in markdown
- Store in Convex database
- Deploy to Netlify
- Updates sync in real-time
### Ordered
1. Fork the repository
2. Set up Convex backend
3. Configure Netlify
4. Start writing
## Blockquotes
> Markdown files in your repo are simpler than a CMS. Version controlled, AI-editable, and no separate admin panel.
## Links
External links open in new tabs: [Convex Docs](https://docs.convex.dev)
Internal links: [Setup Guide](/setup-guide)
## Emphasis
Use **bold** for strong emphasis and _italics_ for lighter emphasis.
## Horizontal Rule
---
## Images
Place images in `public/` and reference them:
```markdown
![Alt text](/image.png)
```
## File Structure Reference
```
content/blog/
├── about-this-blog.md
├── markdown-with-code-examples.md
├── setup-guide.md
└── your-new-post.md
```
## Tips
1. Keep slugs URL-friendly (lowercase, hyphens)
2. Set `published: false` for drafts
3. Run `npm run sync` after adding posts
4. Use descriptive titles for SEO

499
content/blog/setup-guide.md Normal file
View File

@@ -0,0 +1,499 @@
---
title: "Fork and Deploy Your Own Markdown Blog"
description: "Step-by-step guide to fork this blog, set up Convex backend, and deploy to Netlify in under 10 minutes."
date: "2025-01-14"
slug: "setup-guide"
published: true
tags: ["convex", "netlify", "tutorial", "deployment"]
readTime: "8 min read"
---
# Fork and Deploy Your Own Markdown Blog
This guide walks you through forking this markdown blog, setting up your Convex backend, and deploying to Netlify. The entire process takes about 10 minutes.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Step 1: Fork the Repository](#step-1-fork-the-repository)
- [Step 2: Set Up Convex](#step-2-set-up-convex)
- [Step 3: Sync Your Blog Posts](#step-3-sync-your-blog-posts)
- [Step 4: Run Locally](#step-4-run-locally)
- [Step 5: Get Your Convex HTTP URL](#step-5-get-your-convex-http-url)
- [Step 6: Configure Netlify Redirects](#step-6-configure-netlify-redirects)
- [Step 7: Deploy to Netlify](#step-7-deploy-to-netlify)
- [Step 8: Set Up Production Convex](#step-8-set-up-production-convex)
- [Writing Blog Posts](#writing-blog-posts)
- [Adding Images](#adding-images)
- [Customizing Your Blog](#customizing-your-blog)
- [API Endpoints](#api-endpoints)
- [Troubleshooting](#troubleshooting)
- [Project Structure](#project-structure)
- [Next Steps](#next-steps)
## Prerequisites
Before you start, make sure you have:
- Node.js 18 or higher installed
- A GitHub account
- A Convex account (free at [convex.dev](https://convex.dev))
- A Netlify account (free at [netlify.com](https://netlify.com))
## Step 1: Fork the Repository
Fork the repository to your GitHub account:
```bash
# Clone your forked repo
git clone https://github.com/waynesutton/markdown-site.git
cd markdown-site
# Install dependencies
npm install
```
## Step 2: Set Up Convex
Convex is the backend that stores your blog posts and serves the API endpoints.
### Create a Convex Project
Run the Convex development command:
```bash
npx convex dev
```
This will:
1. Prompt you to log in to Convex (opens browser)
2. Ask you to create a new project or select an existing one
3. Generate a `.env.local` file with your `VITE_CONVEX_URL`
Keep this terminal running during development. It syncs your Convex functions automatically.
### Verify the Schema
The schema is already defined in `convex/schema.ts`:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
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()),
readTime: v.optional(v.string()),
lastSyncedAt: v.optional(v.number()),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
viewCounts: defineTable({
slug: v.string(),
count: v.number(),
}).index("by_slug", ["slug"]),
});
```
## Step 3: Sync Your Blog Posts
Blog posts live in `content/blog/` as markdown files. Sync them to Convex:
```bash
npm run sync
```
This reads all markdown files, parses the frontmatter, and uploads them to your Convex database.
## Step 4: Run Locally
Start the development server:
```bash
npm run dev
```
Open [http://localhost:5173](http://localhost:5173) to see your blog.
## Step 5: Get Your Convex HTTP URL
Your Convex deployment has two URLs:
- **Client URL**: `https://your-deployment.convex.cloud` (for the React app)
- **HTTP URL**: `https://your-deployment.convex.site` (for API endpoints)
Find your deployment name in the Convex dashboard or check `.env.local`:
```bash
# Your .env.local contains something like:
VITE_CONVEX_URL=https://happy-animal-123.convex.cloud
```
The HTTP URL uses `.convex.site` instead of `.convex.cloud`:
```
https://happy-animal-123.convex.site
```
## Step 6: Configure Netlify Redirects
Open `netlify.toml` and replace `YOUR_CONVEX_DEPLOYMENT` with your actual deployment name:
```toml
# Before
[[redirects]]
from = "/rss.xml"
to = "https://YOUR_CONVEX_DEPLOYMENT.convex.site/rss.xml"
# After (example)
[[redirects]]
from = "/rss.xml"
to = "https://happy-animal-123.convex.site/rss.xml"
```
Update all redirect rules with your deployment name:
- `/rss.xml`
- `/rss-full.xml`
- `/sitemap.xml`
- `/api/posts`
- `/api/post`
- `/meta/post`
## Step 7: Deploy to Netlify
For detailed Convex + Netlify integration, see the official [Convex Netlify Deployment Guide](https://docs.convex.dev/production/hosting/netlify).
### Option A: Netlify CLI
```bash
# Install Netlify CLI
npm install -g netlify-cli
# Login to Netlify
netlify login
# Initialize site
netlify init
# Deploy
npm run deploy
```
### Option B: Netlify Dashboard
1. Go to [app.netlify.com](https://app.netlify.com)
2. Click "Add new site" then "Import an existing project"
3. Connect your GitHub repository
4. Configure build settings:
- Build command: `npx convex deploy --cmd 'npm run build'`
- Publish directory: `dist`
5. Add environment variables:
- `CONVEX_DEPLOY_KEY`: Generate from [Convex Dashboard](https://dashboard.convex.dev) > Project Settings > Deploy Key
6. Click "Deploy site"
The `CONVEX_DEPLOY_KEY` allows Netlify to automatically deploy your Convex functions and set the correct `VITE_CONVEX_URL` on each build.
## Step 8: Set Up Production Convex
For production, deploy your Convex functions:
```bash
npx convex deploy
```
This creates a production deployment. Update your Netlify environment variable with the production URL if different.
## Writing Blog Posts
Create new posts in `content/blog/`:
```markdown
---
title: "Your Post Title"
description: "A brief description for SEO and social sharing"
date: "2025-01-15"
slug: "your-post-url"
published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
image: "/images/my-post-image.png"
---
Your markdown content here...
```
### Frontmatter Fields
| Field | Required | Description |
| ------------- | -------- | ----------------------------- |
| `title` | Yes | Post title |
| `description` | Yes | Short description for SEO |
| `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | URL path (must be unique) |
| `published` | Yes | Set to `true` to publish |
| `tags` | Yes | Array of topic tags |
| `readTime` | No | Estimated reading time |
| `image` | No | Header/Open Graph image URL |
### Adding Images
Place images in `public/images/` and reference them in your posts:
**Header/OG Image (in frontmatter):**
```yaml
image: "/images/my-header.png"
```
This image appears when sharing on social media. Recommended: 1200x630 pixels.
**Inline Images (in content):**
```markdown
![Alt text description](/images/screenshot.png)
```
**External Images:**
```markdown
![Photo](https://images.unsplash.com/photo-xxx?w=800)
```
### Sync After Adding Posts
After adding or editing posts, sync to Convex.
**Development sync:**
```bash
npm run sync
```
**Production sync:**
First, create `.env.production.local` in your project root:
```
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
```
Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment.
Then sync:
```bash
npm run sync:prod
```
### Environment Files
| File | Purpose | Created by |
| ----------------------- | ------------------- | ---------------------------- |
| `.env.local` | Dev deployment URL | `npx convex dev` (automatic) |
| `.env.production.local` | Prod deployment URL | You (manual) |
Both files are gitignored. Each developer creates their own local environment files.
## Customizing Your Blog
### Change the Favicon
Replace `public/favicon.svg` with your own SVG icon. The default is a rounded square with the letter "m":
```xml
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="#000000"/>
<text x="256" y="330" text-anchor="middle" font-size="300" font-weight="800" fill="#ffffff">m</text>
</svg>
```
To use a different letter or icon, edit the SVG directly or replace the file.
### Change the Site Logo
The logo appears on the homepage. Edit `src/pages/Home.tsx`:
```typescript
const siteConfig = {
logo: "/images/logo.svg", // Set to null to hide the logo
// ...
};
```
Replace `public/images/logo.svg` with your own logo file. Recommended: SVG format, 512x512 pixels.
### Change the Default Open Graph Image
The default OG image is used when a post does not have an `image` field in its frontmatter. Replace `public/images/og-default.svg` with your own image.
Recommended dimensions: 1200x630 pixels. Supported formats: PNG, JPG, or SVG.
Update the reference in `src/pages/Post.tsx`:
```typescript
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
```
### Update Site Configuration
Edit `src/pages/Home.tsx` to customize:
```typescript
const siteConfig = {
name: "Your Name",
title: "Your Title",
intro: "Your introduction...",
bio: "Your bio...",
featuredEssays: [{ title: "Post Title", slug: "post-slug" }],
links: {
github: "https://github.com/waynesutton/markdown-site",
twitter: "https://twitter.com/yourusername",
},
};
```
### Change the Default Theme
Edit `src/context/ThemeContext.tsx`:
```typescript
const DEFAULT_THEME: Theme = "tan"; // Options: "dark", "light", "tan", "cloud"
```
### Change the Font
The blog uses a serif font by default. To switch to sans-serif, edit `src/styles/global.css`:
```css
body {
/* Sans-serif */
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Serif (default) */
font-family:
"New York",
-apple-system-ui-serif,
ui-serif,
Georgia,
serif;
}
```
### Add Static Pages (Optional)
Create optional pages like About, Projects, or Contact. These appear as navigation links in the top right corner.
1. Create a `content/pages/` directory
2. Add markdown files with frontmatter:
```markdown
---
title: "About"
slug: "about"
published: true
order: 1
---
Your page content here...
```
| Field | Required | Description |
| ----------- | -------- | ----------------------------- |
| `title` | Yes | Page title (shown in nav) |
| `slug` | Yes | URL path (e.g., `/about`) |
| `published` | Yes | Set `true` to show |
| `order` | No | Display order (lower = first) |
3. Run `npm run sync` to sync pages
Pages appear automatically in the navigation when published.
### Update SEO Meta Tags
Edit `index.html` to update:
- Site title
- Meta description
- Open Graph tags
- JSON-LD structured data
### Update llms.txt and robots.txt
Edit `public/llms.txt` and `public/robots.txt` with your site information.
## API Endpoints
Your blog includes these API endpoints for search engines and AI:
| Endpoint | Description |
| ------------------------------ | --------------------------- |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content |
| `/sitemap.xml` | Dynamic XML sitemap |
| `/api/posts` | JSON list of all posts |
| `/api/post?slug=xxx` | Single post as JSON |
| `/api/post?slug=xxx&format=md` | Single post as raw markdown |
## Troubleshooting
### Posts not appearing
1. Check that `published: true` in frontmatter
2. Run `npm run sync` to sync posts
3. Verify posts exist in Convex dashboard
### RSS/Sitemap not working
1. Verify `netlify.toml` has correct Convex URL
2. Check that Convex functions are deployed
3. Test the Convex HTTP URL directly in browser
### Build failures on Netlify
1. Verify `VITE_CONVEX_URL` environment variable is set
2. Check build logs for specific errors
3. Ensure Node.js version is 18 or higher
## Project Structure
```
markdown-site/
├── content/blog/ # Markdown blog posts
├── convex/ # Convex backend functions
│ ├── http.ts # HTTP endpoints
│ ├── posts.ts # Post queries/mutations
│ ├── rss.ts # RSS feed generation
│ └── schema.ts # Database schema
├── public/ # Static assets
│ ├── robots.txt # Crawler rules
│ └── llms.txt # AI agent discovery
├── src/
│ ├── components/ # React components
│ ├── context/ # Theme context
│ ├── pages/ # Page components
│ └── styles/ # Global CSS
├── netlify.toml # Netlify configuration
└── package.json # Dependencies
```
## Next Steps
After deploying:
1. Add your own blog posts
2. Customize the theme colors in `global.css`
3. Update the featured essays list
4. Submit your sitemap to Google Search Console
5. Share your first post
Your blog is now live with real-time updates, SEO optimization, and AI-friendly APIs. Every time you sync new posts, they appear immediately without redeploying.

View File

@@ -0,0 +1,99 @@
---
title: "Using Images in Blog Posts"
description: "Learn how to add header images, inline images, and Open Graph images to your markdown blog posts."
date: "2025-01-18"
slug: "using-images-in-posts"
published: true
tags: ["images", "tutorial", "markdown", "open-graph"]
readTime: "4 min read"
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
---
# Using Images in Blog Posts
This post demonstrates how to add images to your blog posts. You can use header images for social sharing, inline images for content, and set Open Graph images for better link previews.
## Header/Open Graph Images
The `image` field in your frontmatter sets the Open Graph image for social media previews. When someone shares your post on Twitter, LinkedIn, or Slack, this image appears in the preview card.
```yaml
---
title: "Your Post Title"
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
---
```
**Recommended dimensions:** 1200x630 pixels (1.91:1 ratio)
## Inline Images
Add images anywhere in your markdown content using standard syntax:
```markdown
![Alt text description](/images/screenshot.png)
```
Here's an example image from Unsplash:
![Laptop on a wooden desk with coffee and notebook](https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800&h=450&fit=crop)
The alt text appears as a caption below the image.
## Image Sources
You can use images from:
| Source | Example |
| ----------- | --------------------------------- |
| Local files | `/images/my-image.png` |
| Unsplash | `https://images.unsplash.com/...` |
| Cloudinary | `https://res.cloudinary.com/...` |
| Any CDN | Full URL to image |
### Local Images
Place image files in the `public/images/` directory:
```
public/
images/
screenshot.png
diagram.svg
photo.jpg
```
Reference them with a leading slash:
```markdown
![Screenshot](/images/screenshot.png)
```
### External Images
Use the full URL for images hosted elsewhere:
```markdown
![Photo](https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800)
```
Here's a coding-themed image:
![Code on a screen](https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&h=450&fit=crop)
## Best Practices
1. **Use descriptive alt text** for accessibility
2. **Optimize image size** before uploading (compress PNG/JPG)
3. **Use CDN URLs** for external images when possible
4. **Match OG image dimensions** to 1200x630 for social previews
5. **Use SVG** for logos and icons
## Free Image Resources
These sites offer free, high-quality images:
- [Unsplash](https://unsplash.com) - Photos
- [Pexels](https://pexels.com) - Photos and videos
- [unDraw](https://undraw.co) - Illustrations
- [Heroicons](https://heroicons.com) - Icons

30
content/pages/about.md Normal file
View File

@@ -0,0 +1,30 @@
---
title: "About"
slug: "about"
published: true
order: 1
---
This is a markdown site built for writers, developers, and teams who want a fast, real-time publishing workflow.
## What makes it different
Most static site generators require a rebuild every time you publish. This one does not. Write markdown, run a sync command, and your content appears instantly across all connected browsers.
The backend runs on Convex, a reactive database that pushes updates to clients in real time. No polling. No cache invalidation. No deploy cycles for content changes.
## The stack
| Layer | Technology |
| -------- | ------------------ |
| Frontend | React + TypeScript |
| Backend | Convex |
| Styling | CSS variables |
| Hosting | Netlify |
| Content | Markdown |
## Who this is for
Writers who want version control for their content. Developers who want to extend the platform. Teams who need real-time collaboration without a traditional CMS.
Fork it, customize it, ship it.

43
content/pages/contact.md Normal file
View File

@@ -0,0 +1,43 @@
---
title: "Contact"
slug: "contact"
published: true
order: 3
---
You found the contact page. Nice.
## The technical way
This site runs on Convex, which means every page view is a live subscription to the database. You are not reading cached HTML. You are reading data that synced moments ago.
If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required.
```typescript
// A contact form mutation looks like this
export const submitContact = mutation({
args: {
name: v.string(),
email: v.string(),
message: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
...args,
createdAt: Date.now(),
});
},
});
```
## The human way
Open an issue on GitHub. Or find the author on X. Or send a carrier pigeon. Convex does not support those yet, but the team is probably working on it.
## Why Convex
Traditional backends make you write API routes, manage connections, handle caching, and pray nothing breaks at 3am. Convex handles all of that. You write functions. They run in the cloud. Data syncs to clients. Done.
The contact form example above is the entire backend. No Express. No database drivers. No WebSocket setup. Just a function that inserts a row.
That is why this site uses Convex.

261
content/pages/docs.md Normal file
View File

@@ -0,0 +1,261 @@
---
title: "Docs"
slug: "docs"
published: true
order: 0
---
Reference documentation for setting up, customizing, and deploying this markdown site.
## Quick start
```bash
git clone https://github.com/waynesutton/markdown-site.git
cd markdown-site
npm install
npx convex dev
npm run sync
npm run dev
```
Open `http://localhost:5173` to view locally.
## Requirements
- Node.js 18+
- Convex account (free at convex.dev)
- Netlify account (free at netlify.com)
## Project structure
```
markdown-site/
├── content/
│ ├── blog/ # Blog posts (.md)
│ └── pages/ # Static pages (.md)
├── convex/
│ ├── schema.ts # Database schema
│ ├── posts.ts # Post queries/mutations
│ ├── pages.ts # Page queries/mutations
│ ├── http.ts # API endpoints
│ └── rss.ts # RSS generation
├── src/
│ ├── components/ # React components
│ ├── context/ # Theme context
│ ├── pages/ # Route components
│ └── styles/ # CSS
├── public/
│ ├── images/ # Static images
│ ├── robots.txt # Crawler rules
│ └── llms.txt # AI discovery
└── netlify.toml # Deployment config
```
## Content
### Blog posts
Create files in `content/blog/` with frontmatter:
```markdown
---
title: "Post Title"
description: "SEO description"
date: "2025-01-15"
slug: "url-path"
published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
image: "/images/og-image.png"
---
Content here...
```
| 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 |
| `readTime` | No | Display time estimate |
| `image` | No | Open Graph image |
### Static pages
Create files in `content/pages/` with frontmatter:
```markdown
---
title: "Page Title"
slug: "url-path"
published: true
order: 1
---
Content here...
```
| Field | Required | Description |
| ----------- | -------- | ------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
### Syncing content
```bash
# Development
npm run sync
# Production
npm run sync:prod
```
## Configuration
### Site settings
Edit `src/pages/Home.tsx`:
```typescript
const siteConfig = {
name: "Site Name",
title: "Tagline",
logo: "/images/logo.svg", // null to hide
intro: "Introduction text...",
bio: "Bio text...",
featuredEssays: [{ title: "Post Title", slug: "post-slug" }],
links: {
docs: "/docs",
convex: "https://convex.dev",
},
};
```
### Theme
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
Edit `src/context/ThemeContext.tsx`:
```typescript
const DEFAULT_THEME: Theme = "tan";
```
### Font
Edit `src/styles/global.css`:
```css
body {
/* Sans-serif */
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Serif (default) */
font-family: "New York", ui-serif, Georgia, serif;
}
```
### Images
| Image | Location | Size |
| ---------------- | ------------------------------ | -------- |
| Favicon | `public/favicon.svg` | 512x512 |
| Site logo | `public/images/logo.svg` | 512x512 |
| Default OG image | `public/images/og-default.svg` | 1200x630 |
| Post images | `public/images/` | Any |
## API endpoints
| Endpoint | Description |
| ------------------------------ | ----------------------- |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |
| `/api/posts` | JSON post list |
| `/api/post?slug=xxx` | Single post (JSON) |
| `/api/post?slug=xxx&format=md` | Single post (markdown) |
## Deployment
### Netlify setup
1. Connect GitHub repo to Netlify
2. Set build command: `npm run deploy`
3. Set publish directory: `dist`
4. Add env variable: `VITE_CONVEX_URL`
### Convex production
```bash
npx convex deploy
```
### netlify.toml
Replace `YOUR_CONVEX_DEPLOYMENT` with your deployment name:
```toml
[[redirects]]
from = "/rss.xml"
to = "https://YOUR_DEPLOYMENT.convex.site/rss.xml"
status = 200
```
## Convex schema
```typescript
// convex/schema.ts
export default defineSchema({
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()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
pages: defineTable({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
});
```
## Troubleshooting
**Posts not appearing**
- Check `published: true` in frontmatter
- Run `npm run sync`
- Verify in Convex dashboard
**RSS/Sitemap errors**
- Verify `netlify.toml` Convex URL
- Test Convex HTTP URL directly
- Check Convex function deployment
**Build failures**
- Verify `VITE_CONVEX_URL` is set
- Check Node.js version (18+)
- Review Netlify build logs

48
content/pages/projects.md Normal file
View File

@@ -0,0 +1,48 @@
---
title: "Projects"
slug: "projects"
published: true
order: 2
---
This markdown site is open source and built to be extended. Here is what ships out of the box.
## Core Features
**Real-time sync**
Posts update instantly across all browsers. No rebuild, no redeploy.
**Four themes**
Dark, light, tan, and cloud. Switch with one click.
**Markdown authoring**
Write in your editor. Frontmatter handles metadata.
**Static pages**
About, Projects, Contact. Add your own.
## API Endpoints
The site exposes endpoints for search engines and AI agents:
- `/rss.xml` for RSS readers
- `/rss-full.xml` for LLM ingestion
- `/sitemap.xml` for search engines
- `/api/posts` for JSON access
- `/llms.txt` for AI discovery
## Technical Architecture
```
content/ <- Markdown files
blog/ <- Blog posts
pages/ <- Static pages
convex/ <- Backend functions
src/ <- React frontend
```
Convex handles the database, queries, and mutations. The frontend subscribes to data and re-renders when it changes. No REST. No GraphQL. Just reactive functions.
## Extend It
Fork the repo. Add features. The codebase is TypeScript end to end with full type safety from database to UI.

90
convex/README.md Normal file
View File

@@ -0,0 +1,90 @@
# Welcome to your Convex functions directory!
Write your Convex functions here.
See https://docs.convex.dev/functions for more.
A query function that takes two arguments looks like:
```ts
// convex/myFunctions.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.myFunctions.myQueryFunction, {
first: 10,
second: "hello",
});
```
A mutation function looks like:
```ts
// convex/myFunctions.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation.
return await ctx.db.get("messages", id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.myFunctions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.

55
convex/_generated/api.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as http from "../http.js";
import type * as pages from "../pages.js";
import type * as posts from "../posts.js";
import type * as rss from "../rss.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
http: typeof http;
pages: typeof pages;
posts: typeof posts;
rss: typeof rss;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

23
convex/_generated/api.js Normal file
View File

@@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

60
convex/_generated/dataModel.d.ts vendored Normal file
View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

143
convex/_generated/server.d.ts vendored Normal file
View File

@@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

6
convex/convex.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineApp } from "convex/server";
const app = defineApp();
export default app;

276
convex/http.ts Normal file
View File

@@ -0,0 +1,276 @@
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { rssFeed, rssFullFeed } from "./rss";
const http = httpRouter();
// Site configuration
const SITE_URL = process.env.SITE_URL || "https://your-blog.netlify.app";
const SITE_NAME = "Wayne Sutton";
// RSS feed endpoint (descriptions only)
http.route({
path: "/rss.xml",
method: "GET",
handler: rssFeed,
});
// Full RSS feed endpoint (with complete content for LLMs)
http.route({
path: "/rss-full.xml",
method: "GET",
handler: rssFullFeed,
});
// Sitemap.xml endpoint for search engines
http.route({
path: "/sitemap.xml",
method: "GET",
handler: httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
const urls = [
// Homepage
` <url>
<loc>${SITE_URL}/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>`,
// All posts
...posts.map(
(post) => ` <url>
<loc>${SITE_URL}/${post.slug}</loc>
<lastmod>${post.date}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>`,
),
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join("\n")}
</urlset>`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=7200",
},
});
}),
});
// API endpoint: List all posts (JSON for LLMs/agents)
http.route({
path: "/api/posts",
method: "GET",
handler: httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
const response = {
site: SITE_NAME,
url: SITE_URL,
description: "Developer and writer. Building with Convex and AI.",
posts: posts.map((post) => ({
title: post.title,
slug: post.slug,
description: post.description,
date: post.date,
readTime: post.readTime,
tags: post.tags,
url: `${SITE_URL}/${post.slug}`,
markdownUrl: `${SITE_URL}/api/post?slug=${post.slug}`,
})),
};
return new Response(JSON.stringify(response, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"Access-Control-Allow-Origin": "*",
},
});
}),
});
// API endpoint: Get single post as markdown (for LLMs/agents)
http.route({
path: "/api/post",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const slug = url.searchParams.get("slug");
const format = url.searchParams.get("format") || "json";
if (!slug) {
return new Response(JSON.stringify({ error: "Missing slug parameter" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const post = await ctx.runQuery(api.posts.getPostBySlug, { slug });
if (!post) {
return new Response(JSON.stringify({ error: "Post not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Return raw markdown if requested
if (format === "markdown" || format === "md") {
const markdown = `# ${post.title}
> ${post.description}
**Published:** ${post.date}${post.readTime ? ` | **Read time:** ${post.readTime}` : ""}
**Tags:** ${post.tags.join(", ")}
**URL:** ${SITE_URL}/${post.slug}
---
${post.content}`;
return new Response(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"Access-Control-Allow-Origin": "*",
},
});
}
// Default: JSON response
const response = {
title: post.title,
slug: post.slug,
description: post.description,
date: post.date,
readTime: post.readTime,
tags: post.tags,
url: `${SITE_URL}/${post.slug}`,
content: post.content,
};
return new Response(JSON.stringify(response, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"Access-Control-Allow-Origin": "*",
},
});
}),
});
// Escape HTML characters to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Generate Open Graph HTML for a post
function generatePostMetaHtml(post: {
title: string;
description: string;
slug: string;
date: string;
readTime?: string;
}): string {
const siteUrl = process.env.SITE_URL || "https://your-blog.netlify.app";
const siteName = "Wayne Sutton";
const defaultImage = `${siteUrl}/og-image.png`;
const canonicalUrl = `${siteUrl}/${post.slug}`;
const safeTitle = escapeHtml(post.title);
const safeDescription = escapeHtml(post.description);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Basic SEO -->
<title>${safeTitle} | ${siteName}</title>
<meta name="description" content="${safeDescription}">
<link rel="canonical" href="${canonicalUrl}">
<!-- Open Graph -->
<meta property="og:title" content="${safeTitle}">
<meta property="og:description" content="${safeDescription}">
<meta property="og:image" content="${defaultImage}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="${siteName}">
<meta property="article:published_time" content="${post.date}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${safeTitle}">
<meta name="twitter:description" content="${safeDescription}">
<meta name="twitter:image" content="${defaultImage}">
<!-- Redirect to actual page after a brief delay for crawlers -->
<script>
setTimeout(() => {
window.location.href = "${canonicalUrl}";
}, 100);
</script>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 680px; margin: 50px auto; padding: 20px; color: #111;">
<h1 style="font-size: 32px; margin-bottom: 16px;">${safeTitle}</h1>
<p style="color: #666; margin-bottom: 24px;">${safeDescription}</p>
<p style="font-size: 14px; color: #999;">${post.date}${post.readTime ? ` · ${post.readTime}` : ""}</p>
<p style="margin-top: 24px;"><small>Redirecting to full article...</small></p>
</body>
</html>`;
}
// HTTP endpoint for Open Graph metadata
http.route({
path: "/meta/post",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const slug = url.searchParams.get("slug");
if (!slug) {
return new Response("Missing slug parameter", { status: 400 });
}
try {
const post = await ctx.runQuery(api.posts.getPostBySlug, { slug });
if (!post) {
return new Response("Post not found", { status: 404 });
}
const html = generatePostMetaHtml({
title: post.title,
description: post.description,
slug: post.slug,
date: post.date,
readTime: post.readTime,
});
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control":
"public, max-age=60, s-maxage=300, stale-while-revalidate=600",
},
});
} catch {
return new Response("Internal server error", { status: 500 });
}
}),
});
export default http;

141
convex/pages.ts Normal file
View File

@@ -0,0 +1,141 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Get all published pages for navigation
export const getAllPages = query({
args: {},
returns: v.array(
v.object({
_id: v.id("pages"),
slug: v.string(),
title: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
}),
),
handler: async (ctx) => {
const pages = await ctx.db
.query("pages")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Sort by order (lower numbers first), then by title
const sortedPages = pages.sort((a, b) => {
const orderA = a.order ?? 999;
const orderB = b.order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.title.localeCompare(b.title);
});
return sortedPages.map((page) => ({
_id: page._id,
slug: page.slug,
title: page.title,
published: page.published,
order: page.order,
}));
},
});
// Get a single page by slug
export const getPageBySlug = query({
args: {
slug: v.string(),
},
returns: v.union(
v.object({
_id: v.id("pages"),
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx, args) => {
const page = await ctx.db
.query("pages")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!page || !page.published) {
return null;
}
return {
_id: page._id,
slug: page.slug,
title: page.title,
content: page.content,
published: page.published,
order: page.order,
};
},
});
// Public mutation for syncing pages from markdown files
export const syncPagesPublic = mutation({
args: {
pages: v.array(
v.object({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
}),
),
},
returns: v.object({
created: v.number(),
updated: v.number(),
deleted: v.number(),
}),
handler: async (ctx, args) => {
let created = 0;
let updated = 0;
let deleted = 0;
const now = Date.now();
const incomingSlugs = new Set(args.pages.map((p) => p.slug));
// Get all existing pages
const existingPages = await ctx.db.query("pages").collect();
const existingBySlug = new Map(existingPages.map((p) => [p.slug, p]));
// Upsert incoming pages
for (const page of args.pages) {
const existing = existingBySlug.get(page.slug);
if (existing) {
// Update existing page
await ctx.db.patch(existing._id, {
title: page.title,
content: page.content,
published: page.published,
order: page.order,
lastSyncedAt: now,
});
updated++;
} else {
// Create new page
await ctx.db.insert("pages", {
...page,
lastSyncedAt: now,
});
created++;
}
}
// Delete pages that no longer exist in the repo
for (const existing of existingPages) {
if (!incomingSlugs.has(existing.slug)) {
await ctx.db.delete(existing._id);
deleted++;
}
}
return { created, updated, deleted };
},
});

284
convex/posts.ts Normal file
View File

@@ -0,0 +1,284 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
// Get all published posts, sorted by date descending
export const getAllPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Sort by date descending
const sortedPosts = posts.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
// Return without content for list view
return sortedPosts.map((post) => ({
_id: post._id,
_creationTime: post._creationTime,
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
}));
},
});
// Get a single post by slug
export const getPostBySlug = query({
args: {
slug: v.string(),
},
returns: v.union(
v.object({
_id: v.id("posts"),
_creationTime: v.number(),
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
}),
v.null(),
),
handler: async (ctx, args) => {
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post || !post.published) {
return null;
}
return {
_id: post._id,
_creationTime: post._creationTime,
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
};
},
});
// Internal mutation for syncing posts from markdown files
export const syncPosts = internalMutation({
args: {
posts: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
}),
),
},
returns: v.object({
created: v.number(),
updated: v.number(),
deleted: v.number(),
}),
handler: async (ctx, args) => {
let created = 0;
let updated = 0;
let deleted = 0;
const now = Date.now();
const incomingSlugs = new Set(args.posts.map((p) => p.slug));
// Get all existing posts
const existingPosts = await ctx.db.query("posts").collect();
const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p]));
// Upsert incoming posts
for (const post of args.posts) {
const existing = existingBySlug.get(post.slug);
if (existing) {
// Update existing post
await ctx.db.patch(existing._id, {
title: post.title,
description: post.description,
content: post.content,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
lastSyncedAt: now,
});
updated++;
} else {
// Create new post
await ctx.db.insert("posts", {
...post,
lastSyncedAt: now,
});
created++;
}
}
// Delete posts that no longer exist in the repo
for (const existing of existingPosts) {
if (!incomingSlugs.has(existing.slug)) {
await ctx.db.delete(existing._id);
deleted++;
}
}
return { created, updated, deleted };
},
});
// Public mutation wrapper for sync script (no auth required for build-time sync)
export const syncPostsPublic = mutation({
args: {
posts: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
}),
),
},
returns: v.object({
created: v.number(),
updated: v.number(),
deleted: v.number(),
}),
handler: async (ctx, args) => {
let created = 0;
let updated = 0;
let deleted = 0;
const now = Date.now();
const incomingSlugs = new Set(args.posts.map((p) => p.slug));
// Get all existing posts
const existingPosts = await ctx.db.query("posts").collect();
const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p]));
// Upsert incoming posts
for (const post of args.posts) {
const existing = existingBySlug.get(post.slug);
if (existing) {
// Update existing post
await ctx.db.patch(existing._id, {
title: post.title,
description: post.description,
content: post.content,
date: post.date,
published: post.published,
tags: post.tags,
readTime: post.readTime,
image: post.image,
lastSyncedAt: now,
});
updated++;
} else {
// Create new post
await ctx.db.insert("posts", {
...post,
lastSyncedAt: now,
});
created++;
}
}
// Delete posts that no longer exist in the repo
for (const existing of existingPosts) {
if (!incomingSlugs.has(existing.slug)) {
await ctx.db.delete(existing._id);
deleted++;
}
}
return { created, updated, deleted };
},
});
// Public mutation for incrementing view count
export const incrementViewCount = mutation({
args: {
slug: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("viewCounts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
count: existing.count + 1,
});
} else {
await ctx.db.insert("viewCounts", {
slug: args.slug,
count: 1,
});
}
return null;
},
});
// Get view count for a post
export const getViewCount = query({
args: {
slug: v.string(),
},
returns: v.number(),
handler: async (ctx, args) => {
const viewCount = await ctx.db
.query("viewCounts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
return viewCount?.count ?? 0;
},
});

154
convex/rss.ts Normal file
View File

@@ -0,0 +1,154 @@
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
// Site configuration for RSS feed
const SITE_URL = "https://your-blog.netlify.app";
const SITE_TITLE = "Wayne Sutton";
const SITE_DESCRIPTION = "Developer and writer. Building with Convex and AI.";
// Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
// Generate RSS XML from posts (description only)
function generateRssXml(
posts: Array<{
title: string;
description: string;
slug: string;
date: string;
}>,
feedPath: string = "/rss.xml",
): string {
const items = posts
.map((post) => {
const pubDate = new Date(post.date).toUTCString();
const url = `${SITE_URL}/${post.slug}`;
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${url}</link>
<guid>${url}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeXml(post.description)}</description>
</item>`;
})
.join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)}</title>
<link>${SITE_URL}</link>
<description>${escapeXml(SITE_DESCRIPTION)}</description>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${SITE_URL}${feedPath}" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>`;
}
// Generate RSS XML with full content (for LLMs and readers)
function generateFullRssXml(
posts: Array<{
title: string;
description: string;
slug: string;
date: string;
content: string;
readTime?: string;
tags: string[];
}>,
): string {
const items = posts
.map((post) => {
const pubDate = new Date(post.date).toUTCString();
const url = `${SITE_URL}/${post.slug}`;
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${url}</link>
<guid>${url}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeXml(post.description)}</description>
<content:encoded><![CDATA[${post.content}]]></content:encoded>
${post.tags.map((tag) => `<category>${escapeXml(tag)}</category>`).join("\n ")}
</item>`;
})
.join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(SITE_TITLE)} - Full Content</title>
<link>${SITE_URL}</link>
<description>${escapeXml(SITE_DESCRIPTION)} Full article content for readers and AI.</description>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${SITE_URL}/rss-full.xml" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>`;
}
// HTTP action to serve RSS feed (descriptions only)
export const rssFeed = httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
const xml = generateRssXml(
posts.map((post) => ({
title: post.title,
description: post.description,
slug: post.slug,
date: post.date,
})),
);
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=7200",
},
});
});
// HTTP action to serve full RSS feed (with complete content)
export const rssFullFeed = httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
// Fetch full content for each post
const fullPosts = await Promise.all(
posts.map(async (post) => {
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
slug: post.slug,
});
return {
title: post.title,
description: post.description,
slug: post.slug,
date: post.date,
content: fullPost?.content || "",
readTime: post.readTime,
tags: post.tags,
};
}),
);
const xml = generateFullRssXml(fullPosts);
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=7200",
},
});
});

53
convex/schema.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
// Blog posts table
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()),
readTime: v.optional(v.string()),
image: v.optional(v.string()), // Header/OG image URL
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_date", ["date"])
.index("by_published", ["published"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],
})
.searchIndex("search_title", {
searchField: "title",
filterFields: ["published"],
}),
// Static pages (about, projects, contact, etc.)
pages: defineTable({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()), // Display order in nav
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
// View counts for analytics
viewCounts: defineTable({
slug: v.string(),
count: v.number(),
}).index("by_slug", ["slug"]),
// Site configuration (about content, links, etc.)
siteConfig: defineTable({
key: v.string(),
value: v.any(),
}).index("by_key", ["key"]),
});

18
convex/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ES2021", "DOM"],
"module": "ESNext",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*.ts"],
"exclude": ["_generated"]
}

143
files.md Normal file
View File

@@ -0,0 +1,143 @@
# Markdown Site - File Structure
A brief description of each file in the codebase.
## Root Files
| File | Description |
| ---------------- | ---------------------------------------------- |
| `package.json` | Dependencies and scripts for the blog |
| `tsconfig.json` | TypeScript configuration |
| `vite.config.ts` | Vite bundler configuration |
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
| `README.md` | Project documentation |
| `files.md` | This file - codebase structure |
| `changelog.md` | Version history and changes |
| `TASK.md` | Task tracking and project status |
## Source Files (`src/`)
### Entry Points
| File | Description |
| --------------- | ------------------------------------------ |
| `main.tsx` | React app entry point with Convex provider |
| `App.tsx` | Main app component with routing |
| `vite-env.d.ts` | Vite environment type definitions |
### Pages (`src/pages/`)
| File | Description |
| ---------- | ------------------------------------------------------- |
| `Home.tsx` | Landing page with intro, featured essays, and post list |
| `Post.tsx` | Individual blog post view with JSON-LD injection |
### Components (`src/components/`)
| File | Description |
| ---------------------- | ---------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with theme toggle container |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude, Cursor, VS Code) |
### Context (`src/context/`)
| File | Description |
| ------------------ | ---------------------------------------------------- |
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
### Styles (`src/styles/`)
| File | Description |
| ------------ | ---------------------------------------------------------------- |
| `global.css` | Global CSS with theme variables, font config for all four themes |
## Convex Backend (`convex/`)
| File | Description |
| ------------------ | ------------------------------------------------- |
| `schema.ts` | Database schema (posts, pages, viewCounts tables) |
| `posts.ts` | Queries and mutations for blog posts, view counts |
| `pages.ts` | Queries and mutations for static pages |
| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
| `rss.ts` | RSS feed generation (standard and full content) |
| `convex.config.ts` | Convex app configuration |
| `tsconfig.json` | Convex TypeScript configuration |
### HTTP Endpoints (defined in `http.ts`)
| Route | Description |
| --------------- | -------------------------------------- |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines |
| `/api/posts` | JSON list of all posts |
| `/api/post` | Single post as JSON or markdown |
| `/meta/post` | Open Graph HTML for social crawlers |
## Content (`content/blog/`)
Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| Field | Description |
| ------------- | -------------------------------------- |
| `title` | Post title |
| `description` | Short description for SEO |
| `date` | Publication date (YYYY-MM-DD) |
| `slug` | URL path for the post |
| `published` | Whether post is public |
| `tags` | Array of topic tags |
| `readTime` | Estimated reading time |
| `image` | Header/Open Graph image URL (optional) |
## Static Pages (`content/pages/`)
Markdown files for static pages like About, Projects, Contact.
| Field | Description |
| ----------- | ----------------------------------------- |
| `title` | Page title |
| `slug` | URL path for the page |
| `published` | Whether page is public |
| `order` | Display order in navigation (lower first) |
## Scripts (`scripts/`)
| File | Description |
| --------------- | -------------------------------------------- |
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
## Netlify (`netlify/edge-functions/`)
| File | Description |
| ------------ | ------------------------------------------------ |
| `botMeta.ts` | Edge function for social media crawler detection |
## Public Assets (`public/`)
| File | Description |
| ------------- | ---------------------------------------------- |
| `favicon.svg` | Site favicon |
| `_redirects` | SPA redirect rules for static files |
| `robots.txt` | Crawler rules for search engines and AI bots |
| `llms.txt` | AI agent discovery file (llmstxt.org standard) |
### Images (`public/images/`)
| File | Description |
| ---------------- | -------------------------------------------- |
| `logo.svg` | Site logo displayed on homepage |
| `og-default.svg` | Default Open Graph image for social sharing |
| `*.png/jpg/svg` | Blog post images (referenced in frontmatter) |
## Cursor Rules (`.cursor/rules/`)
| File | Description |
| --------------- | ----------------------------------------- |
| `sec-check.mdc` | Security guidelines and audit checklist |
| `dev2.mdc` | Development guidelines and best practices |
| `help.mdc` | Core development guidelines |
| `convex2.mdc` | Convex-specific guidelines and examples |

91
index.html Normal file
View File

@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
<meta
name="description"
content="An open source markdown blog powered by Convex and Netlify. Fork it, customize it, ship it."
/>
<meta name="author" content="Markdown Blog" />
<meta
name="keywords"
content="markdown blog, Convex, Netlify, React, TypeScript, open source, real-time"
/>
<meta name="robots" content="index, follow" />
<!-- Theme -->
<meta name="theme-color" content="#faf8f5" />
<!-- Open Graph -->
<meta
property="og:title"
content="Markdown Blog - Real-time Blog with Convex"
/>
<meta
property="og:description"
content="An open source markdown blog powered by Convex and Netlify. Fork it, customize it, ship it."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://your-blog.netlify.app" />
<meta property="og:site_name" content="Markdown Blog" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta
name="twitter:title"
content="Markdown Blog - Real-time Blog with Convex"
/>
<meta
name="twitter:description"
content="An open source markdown blog powered by Convex and Netlify. Fork it, customize it, ship it."
/>
<!-- RSS Feeds -->
<link
rel="alternate"
type="application/rss+xml"
title="RSS Feed"
href="/rss.xml"
/>
<link
rel="alternate"
type="application/rss+xml"
title="RSS Feed (Full Content)"
href="/rss-full.xml"
/>
<!-- LLM and AI Discovery -->
<link rel="author" href="/llms.txt" />
<!-- JSON-LD Structured Data for Homepage -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Markdown Blog",
"url": "https://your-blog.netlify.app",
"description": "An open source markdown blog powered by Convex and Netlify.",
"author": {
"@type": "Organization",
"name": "Markdown Blog",
"url": "https://your-blog.netlify.app"
},
"potentialAction": {
"@type": "SearchAction",
"target": "https://your-blog.netlify.app/?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<title>Markdown Blog - Real-time Blog with Convex</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

68
netlify.toml Normal file
View File

@@ -0,0 +1,68 @@
[build]
command = "npm run deploy"
publish = "dist"
[build.environment]
NODE_VERSION = "20"
# Convex HTTP endpoints
# RSS feeds
[[redirects]]
from = "/rss.xml"
to = "https://agreeable-trout-200.convex.site/rss.xml"
status = 200
force = true
[[redirects]]
from = "/rss-full.xml"
to = "https://agreeable-trout-200.convex.site/rss-full.xml"
status = 200
force = true
# Sitemap for search engines
[[redirects]]
from = "/sitemap.xml"
to = "https://agreeable-trout-200.convex.site/sitemap.xml"
status = 200
force = true
# API endpoints for LLMs and agents
[[redirects]]
from = "/api/posts"
to = "https://agreeable-trout-200.convex.site/api/posts"
status = 200
force = true
[[redirects]]
from = "/api/post"
to = "https://agreeable-trout-200.convex.site/api/post"
status = 200
force = true
# Open Graph metadata endpoint
[[redirects]]
from = "/meta/post"
to = "https://agreeable-trout-200.convex.site/meta/post"
status = 200
force = true
# SPA fallback for client-side routing (must be last)
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Edge function for Open Graph bot detection
[[edge_functions]]
path = "/*"
function = "botMeta"
[context.production.environment]
NODE_ENV = "production"
[context.deploy-preview.environment]
NODE_ENV = "development"
[context.branch-deploy.environment]
NODE_ENV = "development"

View File

@@ -0,0 +1,95 @@
import type { Context } from "@netlify/edge-functions";
// List of known social media and search engine bots
const BOTS = [
"facebookexternalhit",
"twitterbot",
"linkedinbot",
"slackbot",
"discordbot",
"telegrambot",
"whatsapp",
"pinterest",
"opengraph",
"opengraphbot",
"bot ",
"crawler",
"embedly",
"vkshare",
"quora link preview",
"redditbot",
"rogerbot",
"showyoubot",
"google",
"bingbot",
"baiduspider",
"duckduckbot",
];
function isBot(userAgent: string | null): boolean {
if (!userAgent) return false;
const ua = userAgent.toLowerCase();
return BOTS.some((bot) => ua.includes(bot));
}
export default async function handler(
request: Request,
context: Context,
): Promise<Response> {
const url = new URL(request.url);
const userAgent = request.headers.get("user-agent");
// Only intercept post pages for bots
const pathParts = url.pathname.split("/").filter(Boolean);
// Skip if it's the home page, static assets, or API routes
if (
pathParts.length === 0 ||
pathParts[0].includes(".") ||
pathParts[0] === "api" ||
pathParts[0] === "_next"
) {
return context.next();
}
// If not a bot, continue to the SPA
if (!isBot(userAgent)) {
return context.next();
}
// For bots, fetch the Open Graph metadata from Convex
const slug = pathParts[0];
const convexUrl =
Deno.env.get("VITE_CONVEX_URL") || Deno.env.get("CONVEX_URL");
if (!convexUrl) {
return context.next();
}
try {
// Construct the Convex site URL for the HTTP endpoint
const convexSiteUrl = convexUrl.replace(".cloud", ".site");
const metaUrl = `${convexSiteUrl}/meta/post?slug=${encodeURIComponent(slug)}`;
const response = await fetch(metaUrl, {
headers: {
Accept: "text/html",
},
});
if (response.ok) {
const html = await response.text();
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "public, max-age=60, s-maxage=300",
},
});
}
// If meta endpoint fails, fall back to SPA
return context.next();
} catch {
return context.next();
}
}

6414
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "markdown-site",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:convex": "convex dev",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"sync": "npx tsx scripts/sync-posts.ts",
"sync:prod": "SYNC_ENV=production npx tsx scripts/sync-posts.ts",
"deploy": "npm run sync && npm run build",
"deploy:prod": "npx convex deploy && npm run sync:prod"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"convex": "^1.17.4",
"date-fns": "^3.3.1",
"gray-matter": "^4.0.3",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.22.0",
"react-syntax-highlighter": "^15.5.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@types/react-syntax-highlighter": "^15.5.11",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"dotenv": "^16.4.5",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"tsx": "^4.7.1",
"typescript": "^5.2.2",
"vite": "^5.1.4"
}
}

8
public/_redirects Normal file
View File

@@ -0,0 +1,8 @@
# Static files served directly
/robots.txt /robots.txt 200
/llms.txt /llms.txt 200
/favicon.svg /favicon.svg 200
# SPA fallback for all other routes
/* /index.html 200

10
public/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="m logo">
<rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="#000000"/>
<text x="256" y="330"
text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji','Segoe UI Emoji'"
font-size="300"
font-weight="800"
fill="#ffffff"
letter-spacing="-8">m</text>
</svg>

After

Width:  |  Height:  |  Size: 505 B

10
public/images/logo.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="m logo">
<rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="#000000"/>
<text x="256" y="330"
text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji','Segoe UI Emoji'"
font-size="300"
font-weight="800"
fill="#ffffff"
letter-spacing="-8">m</text>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="markdown blog">
<rect x="0" y="0" width="1200" height="630" rx="48" ry="48" fill="#000000"/>
<text x="600" y="360"
text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial"
font-size="140"
font-weight="700"
fill="#ffffff"
letter-spacing="-2">markdown blog</text>
</svg>

After

Width:  |  Height:  |  Size: 487 B

31
public/llms.txt Normal file
View File

@@ -0,0 +1,31 @@
# llms.txt - Information for AI assistants and LLMs
# Learn more: https://llmstxt.org/
> This is an open source markdown blog powered by Convex and Netlify. Fork it, customize it, ship it.
# Site Information
- Name: Markdown Blog
- URL: https://your-blog.netlify.app
- Description: Real-time markdown blog with Convex backend and Netlify deployment.
- Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source
# Content Access
- RSS Feed: /rss.xml (all posts with descriptions)
- Full RSS: /rss-full.xml (all posts with full content)
- Markdown API: /api/posts (JSON list of all posts)
- Single Post Markdown: /api/post?slug={slug} (full markdown content)
- Sitemap: /sitemap.xml
# How to Use This Site
1. Fetch /api/posts for a list of all published posts
2. Use /api/post?slug={slug} to get full markdown content of any post
3. Subscribe to /rss-full.xml for complete article content
# Permissions
- AI assistants may read and summarize content from this site
- Content may be used for training with attribution
- Please link back to original articles when citing
# Links
- Convex: https://convex.dev
- Netlify: https://netlify.com

33
public/robots.txt Normal file
View File

@@ -0,0 +1,33 @@
# robots.txt for Wayne Sutton's Blog
# https://www.robotstxt.org/
User-agent: *
Allow: /
# Sitemaps
Sitemap: https://your-blog.netlify.app/sitemap.xml
# AI and LLM crawlers
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Applebot-Extended
Allow: /
# Cache directive
Crawl-delay: 1

289
scripts/sync-posts.ts Normal file
View File

@@ -0,0 +1,289 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import dotenv from "dotenv";
// Load environment variables based on SYNC_ENV
const isProduction = process.env.SYNC_ENV === "production";
if (isProduction) {
// Production: load .env.production.local first
dotenv.config({ path: ".env.production.local" });
console.log("Syncing to PRODUCTION deployment...\n");
} else {
// Development: load .env.local
dotenv.config({ path: ".env.local" });
}
dotenv.config();
const CONTENT_DIR = path.join(process.cwd(), "content", "blog");
const PAGES_DIR = path.join(process.cwd(), "content", "pages");
interface PostFrontmatter {
title: string;
description: string;
date: string;
slug: string;
published: boolean;
tags: string[];
readTime?: string;
image?: string; // Header/OG image URL
}
interface ParsedPost {
slug: string;
title: string;
description: string;
content: string;
date: string;
published: boolean;
tags: string[];
readTime?: string;
image?: string; // Header/OG image URL
}
// Page frontmatter (for static pages like About, Projects, Contact)
interface PageFrontmatter {
title: string;
slug: string;
published: boolean;
order?: number; // Display order in navigation
}
interface ParsedPage {
slug: string;
title: string;
content: string;
published: boolean;
order?: number;
}
// Calculate reading time based on word count
function calculateReadTime(content: string): string {
const wordsPerMinute = 200;
const wordCount = content.split(/\s+/).length;
const minutes = Math.ceil(wordCount / wordsPerMinute);
return `${minutes} min read`;
}
// Parse a single markdown file
function parseMarkdownFile(filePath: string): ParsedPost | null {
try {
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(fileContent);
const frontmatter = data as Partial<PostFrontmatter>;
// Validate required fields
if (!frontmatter.title || !frontmatter.date || !frontmatter.slug) {
console.warn(`Skipping ${filePath}: missing required frontmatter fields`);
return null;
}
return {
slug: frontmatter.slug,
title: frontmatter.title,
description: frontmatter.description || "",
content: content.trim(),
date: frontmatter.date,
published: frontmatter.published ?? true,
tags: frontmatter.tags || [],
readTime: frontmatter.readTime || calculateReadTime(content),
image: frontmatter.image, // Header/OG image URL
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);
return null;
}
}
// Get all markdown files from the content directory
function getAllMarkdownFiles(): string[] {
if (!fs.existsSync(CONTENT_DIR)) {
console.log(`Creating content directory: ${CONTENT_DIR}`);
fs.mkdirSync(CONTENT_DIR, { recursive: true });
return [];
}
const files = fs.readdirSync(CONTENT_DIR);
return files
.filter((file) => file.endsWith(".md"))
.map((file) => path.join(CONTENT_DIR, file));
}
// Parse a single page markdown file
function parsePageFile(filePath: string): ParsedPage | null {
try {
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(fileContent);
const frontmatter = data as Partial<PageFrontmatter>;
// Validate required fields
if (!frontmatter.title || !frontmatter.slug) {
console.warn(
`Skipping page ${filePath}: missing required frontmatter fields`,
);
return null;
}
return {
slug: frontmatter.slug,
title: frontmatter.title,
content: content.trim(),
published: frontmatter.published ?? true,
order: frontmatter.order,
};
} catch (error) {
console.error(`Error parsing page ${filePath}:`, error);
return null;
}
}
// Get all page markdown files from the pages directory
function getAllPageFiles(): string[] {
if (!fs.existsSync(PAGES_DIR)) {
// Pages directory is optional, don't create it automatically
return [];
}
const files = fs.readdirSync(PAGES_DIR);
return files
.filter((file) => file.endsWith(".md"))
.map((file) => path.join(PAGES_DIR, file));
}
// Main sync function
async function syncPosts() {
console.log("Starting post sync...\n");
// Get Convex URL from environment
const convexUrl = process.env.VITE_CONVEX_URL || process.env.CONVEX_URL;
if (!convexUrl) {
console.error(
"Error: VITE_CONVEX_URL or CONVEX_URL environment variable is not set",
);
process.exit(1);
}
// Initialize Convex client
const client = new ConvexHttpClient(convexUrl);
// Get all markdown files
const markdownFiles = getAllMarkdownFiles();
console.log(`Found ${markdownFiles.length} markdown files\n`);
if (markdownFiles.length === 0) {
console.log("No markdown files found. Creating sample post...");
createSamplePost();
// Re-read files after creating sample
const newFiles = getAllMarkdownFiles();
markdownFiles.push(...newFiles);
}
// Parse all markdown files
const posts: ParsedPost[] = [];
for (const filePath of markdownFiles) {
const post = parseMarkdownFile(filePath);
if (post) {
posts.push(post);
console.log(`Parsed: ${post.title} (${post.slug})`);
}
}
console.log(`\nSyncing ${posts.length} posts to Convex...\n`);
// Sync posts to Convex
try {
const result = await client.mutation(api.posts.syncPostsPublic, { posts });
console.log("Sync complete!");
console.log(` Created: ${result.created}`);
console.log(` Updated: ${result.updated}`);
console.log(` Deleted: ${result.deleted}`);
} catch (error) {
console.error("Error syncing posts:", error);
process.exit(1);
}
// Sync pages if pages directory exists
const pageFiles = getAllPageFiles();
if (pageFiles.length > 0) {
console.log(`\nFound ${pageFiles.length} page files\n`);
const pages: ParsedPage[] = [];
for (const filePath of pageFiles) {
const page = parsePageFile(filePath);
if (page) {
pages.push(page);
console.log(`Parsed page: ${page.title} (${page.slug})`);
}
}
if (pages.length > 0) {
console.log(`\nSyncing ${pages.length} pages to Convex...\n`);
try {
const pageResult = await client.mutation(api.pages.syncPagesPublic, {
pages,
});
console.log("Pages sync complete!");
console.log(` Created: ${pageResult.created}`);
console.log(` Updated: ${pageResult.updated}`);
console.log(` Deleted: ${pageResult.deleted}`);
} catch (error) {
console.error("Error syncing pages:", error);
process.exit(1);
}
}
}
}
// Create a sample post if none exist
function createSamplePost() {
const samplePost = `---
title: "Hello World"
description: "Welcome to my blog. This is my first post."
date: "${new Date().toISOString().split("T")[0]}"
slug: "hello-world"
published: true
tags: ["introduction", "blog"]
---
# Hello World
Welcome to my blog! This is my first post.
## What to Expect
I'll be writing about:
- **Development**: Building applications with modern tools
- **AI**: Exploring artificial intelligence and machine learning
- **Productivity**: Tips and tricks for getting things done
## Code Example
Here's a simple TypeScript example:
\`\`\`typescript
function greet(name: string): string {
return \`Hello, \${name}!\`;
}
console.log(greet("World"));
\`\`\`
## Stay Tuned
More posts coming soon. Thanks for reading!
`;
const filePath = path.join(CONTENT_DIR, "hello-world.md");
fs.writeFileSync(filePath, samplePost);
console.log(`Created sample post: ${filePath}`);
}
// Run the sync
syncPosts().catch(console.error);

18
src/App.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Post from "./pages/Post";
import Layout from "./components/Layout";
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/:slug" element={<Post />} />
</Routes>
</Layout>
);
}
export default App;

413
src/components/BlogPost.tsx Normal file
View File

@@ -0,0 +1,413 @@
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Copy, Check } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
// Copy button component for code blocks
function CodeCopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
className="code-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
);
}
// Cursor Dark Theme colors for syntax highlighting
const cursorDarkTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#d4d4d4",
background: "#1e1e1e",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
},
'pre[class*="language-"]': {
color: "#d4d4d4",
background: "#1e1e1e",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
padding: "1.5em",
margin: "1.5em 0",
overflow: "auto" as const,
borderRadius: "8px",
},
comment: { color: "#6a9955", fontStyle: "italic" },
prolog: { color: "#6a9955" },
doctype: { color: "#6a9955" },
cdata: { color: "#6a9955" },
punctuation: { color: "#d4d4d4" },
property: { color: "#9cdcfe" },
tag: { color: "#569cd6" },
boolean: { color: "#569cd6" },
number: { color: "#b5cea8" },
constant: { color: "#4fc1ff" },
symbol: { color: "#4fc1ff" },
deleted: { color: "#f44747" },
selector: { color: "#d7ba7d" },
"attr-name": { color: "#92c5f6" },
string: { color: "#ce9178" },
char: { color: "#ce9178" },
builtin: { color: "#569cd6" },
inserted: { color: "#6a9955" },
operator: { color: "#d4d4d4" },
entity: { color: "#dcdcaa" },
url: { color: "#9cdcfe", textDecoration: "underline" },
variable: { color: "#9cdcfe" },
atrule: { color: "#569cd6" },
"attr-value": { color: "#ce9178" },
function: { color: "#dcdcaa" },
"function-variable": { color: "#dcdcaa" },
keyword: { color: "#569cd6" },
regex: { color: "#d16969" },
important: { color: "#569cd6", fontWeight: "bold" },
bold: { fontWeight: "bold" },
italic: { fontStyle: "italic" },
namespace: { opacity: 0.7 },
"class-name": { color: "#4ec9b0" },
parameter: { color: "#9cdcfe" },
decorator: { color: "#dcdcaa" },
};
// Cursor Light Theme colors for syntax highlighting
const cursorLightTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#171717",
background: "#f5f5f5",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
},
'pre[class*="language-"]': {
color: "#171717",
background: "#f5f5f5",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
padding: "1.5em",
margin: "1.5em 0",
overflow: "auto" as const,
borderRadius: "8px",
},
comment: { color: "#6a737d", fontStyle: "italic" },
prolog: { color: "#6a737d" },
doctype: { color: "#6a737d" },
cdata: { color: "#6a737d" },
punctuation: { color: "#24292e" },
property: { color: "#005cc5" },
tag: { color: "#22863a" },
boolean: { color: "#005cc5" },
number: { color: "#005cc5" },
constant: { color: "#005cc5" },
symbol: { color: "#e36209" },
deleted: { color: "#b31d28", background: "#ffeef0" },
selector: { color: "#22863a" },
"attr-name": { color: "#6f42c1" },
string: { color: "#032f62" },
char: { color: "#032f62" },
builtin: { color: "#005cc5" },
inserted: { color: "#22863a", background: "#f0fff4" },
operator: { color: "#d73a49" },
entity: { color: "#6f42c1" },
url: { color: "#005cc5", textDecoration: "underline" },
variable: { color: "#e36209" },
atrule: { color: "#005cc5" },
"attr-value": { color: "#032f62" },
function: { color: "#6f42c1" },
"function-variable": { color: "#6f42c1" },
keyword: { color: "#d73a49" },
regex: { color: "#032f62" },
important: { color: "#d73a49", fontWeight: "bold" },
bold: { fontWeight: "bold" },
italic: { fontStyle: "italic" },
namespace: { opacity: 0.7 },
"class-name": { color: "#6f42c1" },
parameter: { color: "#24292e" },
decorator: { color: "#6f42c1" },
};
// Tan Theme colors for syntax highlighting
const cursorTanTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#1a1a1a",
background: "#f0ece4",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
},
'pre[class*="language-"]': {
color: "#1a1a1a",
background: "#f0ece4",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
padding: "1.5em",
margin: "1.5em 0",
overflow: "auto" as const,
borderRadius: "8px",
},
comment: { color: "#7a7a7a", fontStyle: "italic" },
prolog: { color: "#7a7a7a" },
doctype: { color: "#7a7a7a" },
cdata: { color: "#7a7a7a" },
punctuation: { color: "#1a1a1a" },
property: { color: "#8b7355" },
tag: { color: "#8b5a2b" },
boolean: { color: "#8b5a2b" },
number: { color: "#8b5a2b" },
constant: { color: "#8b5a2b" },
symbol: { color: "#a67c52" },
deleted: { color: "#b31d28" },
selector: { color: "#6b8e23" },
"attr-name": { color: "#8b7355" },
string: { color: "#6b8e23" },
char: { color: "#6b8e23" },
builtin: { color: "#8b5a2b" },
inserted: { color: "#6b8e23" },
operator: { color: "#a67c52" },
entity: { color: "#8b7355" },
url: { color: "#8b7355", textDecoration: "underline" },
variable: { color: "#a67c52" },
atrule: { color: "#8b5a2b" },
"attr-value": { color: "#6b8e23" },
function: { color: "#8b7355" },
"function-variable": { color: "#8b7355" },
keyword: { color: "#8b5a2b" },
regex: { color: "#6b8e23" },
important: { color: "#8b5a2b", fontWeight: "bold" },
bold: { fontWeight: "bold" },
italic: { fontStyle: "italic" },
namespace: { opacity: 0.7 },
"class-name": { color: "#8b7355" },
parameter: { color: "#1a1a1a" },
decorator: { color: "#8b7355" },
};
interface BlogPostProps {
content: string;
}
// Generate slug from heading text for anchor links
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim();
}
// Extract text content from React children
function getTextContent(children: React.ReactNode): string {
if (typeof children === "string") return children;
if (Array.isArray(children)) {
return children.map(getTextContent).join("");
}
if (children && typeof children === "object" && "props" in children) {
return getTextContent((children as React.ReactElement).props.children);
}
return "";
}
export default function BlogPost({ content }: BlogPostProps) {
const { theme } = useTheme();
const getCodeTheme = () => {
switch (theme) {
case "dark":
return cursorDarkTheme;
case "light":
return cursorLightTheme;
case "tan":
return cursorTanTheme;
default:
return cursorDarkTheme;
}
};
return (
<article className="blog-post-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match && !className;
if (isInline) {
return (
<code className="inline-code" {...props}>
{children}
</code>
);
}
const codeString = String(children).replace(/\n$/, "");
return (
<div className="code-block-wrapper">
{match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} />
<SyntaxHighlighter
style={getCodeTheme()}
language={match ? match[1] : "text"}
PreTag="div"
>
{codeString}
</SyntaxHighlighter>
</div>
);
},
img({ src, alt }) {
return (
<span className="blog-image-wrapper">
<img
src={src}
alt={alt || ""}
className="blog-image"
loading="lazy"
/>
{alt && <span className="blog-image-caption">{alt}</span>}
</span>
);
},
a({ href, children }) {
const isExternal = href?.startsWith("http");
return (
<a
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="blog-link"
>
{children}
</a>
);
},
blockquote({ children }) {
return (
<blockquote className="blog-blockquote">{children}</blockquote>
);
},
h1({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h1 id={id} className="blog-h1">
{children}
</h1>
);
},
h2({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h2 id={id} className="blog-h2">
{children}
</h2>
);
},
h3({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h3 id={id} className="blog-h3">
{children}
</h3>
);
},
h4({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h4 id={id} className="blog-h4">
{children}
</h4>
);
},
h5({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h5 id={id} className="blog-h5">
{children}
</h5>
);
},
ul({ children }) {
return <ul className="blog-ul">{children}</ul>;
},
ol({ children }) {
return <ol className="blog-ol">{children}</ol>;
},
li({ children }) {
return <li className="blog-li">{children}</li>;
},
hr() {
return <hr className="blog-hr" />;
},
}}
>
{content}
</ReactMarkdown>
</article>
);
}

View File

@@ -0,0 +1,188 @@
import { useState, useRef, useEffect } from "react";
import { Copy, MessageSquare, Sparkles, Terminal, Code } from "lucide-react";
interface CopyPageDropdownProps {
title: string;
content: string;
url: string;
}
// Converts the blog post to markdown format for LLMs
function formatAsMarkdown(title: string, content: string, url: string): string {
return `# ${title}\n\nSource: ${url}\n\n${content}`;
}
export default function CopyPageDropdown({
title,
content,
url,
}: CopyPageDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Handle copy page action
const handleCopyPage = async () => {
const markdown = formatAsMarkdown(title, content, url);
await navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => {
setCopied(false);
setIsOpen(false);
}, 1500);
};
// Open in ChatGPT with the page content
const handleOpenInChatGPT = () => {
const markdown = formatAsMarkdown(title, content, url);
const encodedText = encodeURIComponent(
`Please analyze this article:\n\n${markdown}`,
);
window.open(`https://chat.openai.com/?q=${encodedText}`, "_blank");
setIsOpen(false);
};
// Open in Claude with the page content
const handleOpenInClaude = () => {
const markdown = formatAsMarkdown(title, content, url);
const encodedText = encodeURIComponent(
`Please analyze this article:\n\n${markdown}`,
);
window.open(`https://claude.ai/new?q=${encodedText}`, "_blank");
setIsOpen(false);
};
// Open Cursor MCP connection page
const handleConnectToCursor = () => {
window.open("https://cursor.sh/settings/mcp", "_blank");
setIsOpen(false);
};
// Open VS Code MCP connection page
const handleConnectToVSCode = () => {
window.open(
"https://marketplace.visualstudio.com/items?itemName=anthropics.claude-code",
"_blank",
);
setIsOpen(false);
};
return (
<div className="copy-page-dropdown" ref={dropdownRef}>
{/* Trigger button */}
<button
className="copy-page-trigger"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
>
<Copy size={14} />
<span>Copy page</span>
<svg
className={`dropdown-chevron ${isOpen ? "open" : ""}`}
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 4L5 6.5L7.5 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="copy-page-menu">
{/* Copy page option */}
<button className="copy-page-item" onClick={handleCopyPage}>
<Copy size={16} className="copy-page-icon" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
{copied ? "Copied!" : "Copy page"}
</span>
<span className="copy-page-item-desc">
Copy page as Markdown for LLMs
</span>
</div>
</button>
{/* Open in ChatGPT */}
<button className="copy-page-item" onClick={handleOpenInChatGPT}>
<MessageSquare size={16} className="copy-page-icon" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Open in ChatGPT
<span className="external-arrow"></span>
</span>
<span className="copy-page-item-desc">
Ask questions about this page
</span>
</div>
</button>
{/* Open in Claude */}
<button className="copy-page-item" onClick={handleOpenInClaude}>
<Sparkles size={16} className="copy-page-icon" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Open in Claude
<span className="external-arrow"></span>
</span>
<span className="copy-page-item-desc">
Ask questions about this page
</span>
</div>
</button>
{/* Connect to Cursor */}
<button className="copy-page-item" onClick={handleConnectToCursor}>
<Terminal size={16} className="copy-page-icon" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Connect to Cursor
<span className="external-arrow"></span>
</span>
<span className="copy-page-item-desc">
Install MCP Server on Cursor
</span>
</div>
</button>
{/* Connect to VS Code */}
<button className="copy-page-item" onClick={handleConnectToVSCode}>
<Code size={16} className="copy-page-icon" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Connect to VS Code
<span className="external-arrow"></span>
</span>
<span className="copy-page-item-desc">
Install MCP Server on VS Code
</span>
</div>
</button>
</div>
)}
</div>
);
}

41
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import ThemeToggle from "./ThemeToggle";
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
// Fetch published pages for navigation
const pages = useQuery(api.pages.getAllPages);
return (
<div className="layout">
{/* Top navigation bar with page links and theme toggle */}
<div className="top-nav">
{/* Page navigation links (optional pages like About, Projects, Contact) */}
{pages && pages.length > 0 && (
<nav className="page-nav">
{pages.map((page) => (
<Link
key={page.slug}
to={`/${page.slug}`}
className="page-nav-link"
>
{page.title}
</Link>
))}
</nav>
)}
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
</div>
</div>
<main className="main-content">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Link } from "react-router-dom";
import { format, parseISO } from "date-fns";
interface Post {
_id: string;
slug: string;
title: string;
description: string;
date: string;
readTime?: string;
tags: string[];
}
interface PostListProps {
posts: Post[];
}
// Group posts by year
function groupByYear(posts: Post[]): Record<string, Post[]> {
return posts.reduce(
(acc, post) => {
const year = post.date.substring(0, 4);
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
},
{} as Record<string, Post[]>
);
}
export default function PostList({ posts }: PostListProps) {
// Sort posts by date descending
const sortedPosts = [...posts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const groupedPosts = groupByYear(sortedPosts);
const years = Object.keys(groupedPosts).sort((a, b) => Number(b) - Number(a));
return (
<div className="post-list">
{years.map((year) => (
<div key={year} className="post-year-group">
<h2 className="year-heading">{year}</h2>
<ul className="posts">
{groupedPosts[year].map((post) => (
<li key={post._id} className="post-item">
<Link to={`/${post.slug}`} className="post-link">
<span className="post-title">{post.title}</span>
<span className="post-meta">
{post.readTime && (
<span className="post-read-time">{post.readTime}</span>
)}
<span className="post-date">
{format(parseISO(post.date), "MMMM d")}
</span>
</span>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { useTheme } from "../context/ThemeContext";
import { Moon, Sun, Cloud } from "lucide-react";
import { Half2Icon } from "@radix-ui/react-icons";
// Theme toggle component using same icons as Better Todo app
// Icons: Moon (dark), Sun (light), Half2Icon (tan), Cloud (cloud)
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
// Get the appropriate icon for current theme
const getIcon = () => {
switch (theme) {
case "dark":
return <Moon size={18} />;
case "light":
return <Sun size={18} />;
case "tan":
// Half2Icon from Radix uses different sizing
return <Half2Icon style={{ width: 18, height: 18 }} />;
case "cloud":
return <Cloud size={18} />;
}
};
// Get theme label for accessibility
const getLabel = () => {
switch (theme) {
case "dark":
return "Dark";
case "light":
return "Light";
case "tan":
return "Tan";
case "cloud":
return "Cloud";
}
};
return (
<button
onClick={toggleTheme}
className="theme-toggle"
aria-label={`Current theme: ${getLabel()}. Click to toggle.`}
title={`Theme: ${getLabel()}`}
>
{getIcon()}
</button>
);
}

View File

@@ -0,0 +1,87 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
// Available theme options
type Theme = "dark" | "light" | "tan" | "cloud";
// Default theme for new users (tan matches warm aesthetic)
const DEFAULT_THEME: Theme = "tan";
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme; // Allow overriding default theme
}
// Get initial theme from localStorage or use default
const getInitialTheme = (defaultTheme: Theme): Theme => {
try {
const saved = localStorage.getItem("blog-theme") as Theme;
if (saved && ["dark", "light", "tan", "cloud"].includes(saved)) {
return saved;
}
} catch {
// localStorage not available
}
return defaultTheme;
};
// Theme color values for meta tag
const themeColors: Record<Theme, string> = {
dark: "#111111",
light: "#ffffff",
tan: "#faf8f5",
cloud: "#f5f5f5",
};
// Update meta theme-color tag for mobile browsers
const updateMetaThemeColor = (theme: Theme) => {
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute("content", themeColors[theme]);
}
};
export function ThemeProvider({ children, defaultTheme = DEFAULT_THEME }: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme(defaultTheme));
// Apply theme to DOM and persist to localStorage
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("blog-theme", theme);
updateMetaThemeColor(theme);
}, [theme]);
// Set theme directly
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
// Cycle through themes: dark -> light -> tan -> cloud -> dark
const toggleTheme = () => {
const themes: Theme[] = ["dark", "light", "tan", "cloud"];
const currentIndex = themes.indexOf(theme);
const nextIndex = (currentIndex + 1) % themes.length;
setThemeState(themes[nextIndex]);
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

22
src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ThemeProvider } from "./context/ThemeContext";
import "./styles/global.css";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ConvexProvider client={convex}>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</ConvexProvider>
</React.StrictMode>
);

97
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import PostList from "../components/PostList";
// Site configuration - customize this for your site
const siteConfig = {
name: "Markdown Site",
title: "Real-time Site with Convex",
// Optional logo/header image (place in public/images/, set to null to hide)
logo: "/images/logo.svg" as string | null,
intro: `An open source markdown blog powered by Convex and deployed on Netlify. Fork it, customize it, ship it.`,
bio: `Write in markdown, sync to a real-time database, and deploy in minutes. Built with React, TypeScript, and Convex for instant updates without rebuilds.`,
featuredEssays: [
{ title: "Setup Guide", slug: "setup-guide" },
{ title: "How to Publish", slug: "how-to-publish" },
{ title: "About This Site", slug: "about-this-blog" },
],
// Links for footer section
links: {
docs: "/setup-guide",
convex: "https://convex.dev",
netlify: "https://netlify.com",
},
};
export default function Home() {
// Fetch published posts from Convex
const posts = useQuery(api.posts.getAllPosts);
return (
<div className="home">
{/* Header section with intro */}
<header className="home-header">
{/* Optional site logo */}
{siteConfig.logo && (
<img
src={siteConfig.logo}
alt={siteConfig.name}
className="home-logo"
/>
)}
<h1 className="home-name">{siteConfig.name}</h1>
<p className="home-intro">{siteConfig.intro}</p>
<p className="home-bio">{siteConfig.bio}</p>
{/* Featured essays section */}
<div className="home-featured">
<p className="home-featured-intro">Get started:</p>
<ul className="home-featured-list">
{siteConfig.featuredEssays.map((essay) => (
<li key={essay.slug}>
<a href={`/${essay.slug}`} className="home-featured-link">
{essay.title}
</a>
</li>
))}
</ul>
</div>
</header>
{/* Blog posts section - no loading state to avoid flash (Convex syncs instantly) */}
<section id="posts" className="home-posts">
{posts === undefined ? null : posts.length === 0 ? (
<p className="no-posts">No posts yet. Check back soon!</p>
) : (
<PostList posts={posts} />
)}
</section>
{/* Footer section */}
<section className="home-footer">
<p className="home-footer-text">
Built with{" "}
<a
href={siteConfig.links.convex}
target="_blank"
rel="noopener noreferrer"
>
Convex
</a>{" "}
for real-time sync and deployed on{" "}
<a
href={siteConfig.links.netlify}
target="_blank"
rel="noopener noreferrer"
>
Netlify
</a>
. Read the <a href={siteConfig.links.docs}>setup guide</a> to fork and
deploy your own.
</p>
</section>
</div>
);
}

260
src/pages/Post.tsx Normal file
View File

@@ -0,0 +1,260 @@
import { useParams, Link, useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import { format, parseISO } from "date-fns";
import { ArrowLeft, Link as LinkIcon, Twitter, Rss } from "lucide-react";
import { useState, useEffect } from "react";
// Site configuration
const SITE_URL = "https://your-site.netlify.app";
const SITE_NAME = "Markdown Site";
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
export default function Post() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
// Check for page first, then post
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
const [copied, setCopied] = useState(false);
// Update page title for static pages
useEffect(() => {
if (!page) return;
document.title = `${page.title} | ${SITE_NAME}`;
return () => {
document.title = SITE_NAME;
};
}, [page]);
// Inject JSON-LD structured data and Open Graph meta tags for blog posts
useEffect(() => {
if (!post || page) return; // Skip if it's a page
const postUrl = `${SITE_URL}/${post.slug}`;
const ogImage = post.image
? post.image.startsWith("http")
? post.image
: `${SITE_URL}${post.image}`
: `${SITE_URL}${DEFAULT_OG_IMAGE}`;
// Create JSON-LD script element
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.description,
datePublished: post.date,
dateModified: post.date,
image: ogImage,
author: {
"@type": "Organization",
name: SITE_NAME,
url: SITE_URL,
},
publisher: {
"@type": "Organization",
name: SITE_NAME,
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": postUrl,
},
url: postUrl,
keywords: post.tags.join(", "),
articleBody: post.content.substring(0, 500),
wordCount: post.content.split(/\s+/).length,
};
const script = document.createElement("script");
script.type = "application/ld+json";
script.id = "json-ld-article";
script.textContent = JSON.stringify(jsonLd);
// Remove existing JSON-LD if present
const existing = document.getElementById("json-ld-article");
if (existing) existing.remove();
document.head.appendChild(script);
// Update page title and meta description
document.title = `${post.title} | ${SITE_NAME}`;
// Helper to update or create meta tag
const updateMeta = (selector: string, attr: string, value: string) => {
let meta = document.querySelector(selector);
if (!meta) {
meta = document.createElement("meta");
const attrName = selector.includes("property=") ? "property" : "name";
const attrValue = selector.match(/["']([^"']+)["']/)?.[1] || "";
meta.setAttribute(attrName, attrValue);
document.head.appendChild(meta);
}
meta.setAttribute(attr, value);
};
// Update meta description
updateMeta('meta[name="description"]', "content", post.description);
// Update Open Graph meta tags
updateMeta('meta[property="og:title"]', "content", post.title);
updateMeta('meta[property="og:description"]', "content", post.description);
updateMeta('meta[property="og:url"]', "content", postUrl);
updateMeta('meta[property="og:image"]', "content", ogImage);
updateMeta('meta[property="og:type"]', "content", "article");
// Update Twitter Card meta tags
updateMeta('meta[name="twitter:title"]', "content", post.title);
updateMeta('meta[name="twitter:description"]', "content", post.description);
updateMeta('meta[name="twitter:image"]', "content", ogImage);
updateMeta('meta[name="twitter:card"]', "content", "summary_large_image");
// Cleanup on unmount
return () => {
const scriptEl = document.getElementById("json-ld-article");
if (scriptEl) scriptEl.remove();
};
}, [post, page]);
// Return null during initial load to avoid flash (Convex data arrives quickly)
if (page === undefined || post === undefined) {
return null;
}
// If it's a static page, render simplified view
if (page) {
return (
<div className="post-page">
<nav className="post-nav">
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
</nav>
<article className="post-article">
<header className="post-header">
<h1 className="post-title">{page.title}</h1>
</header>
<BlogPost content={page.content} />
</article>
</div>
);
}
// Handle not found (neither page nor post)
if (post === null) {
return (
<div className="post-page">
<div className="post-not-found">
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist or has been removed.</p>
<Link to="/" className="back-link">
<ArrowLeft size={16} />
Back to home
</Link>
</div>
</div>
);
}
const handleCopyLink = async () => {
const url = window.location.href;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleShareTwitter = () => {
const text = encodeURIComponent(post.title);
const url = encodeURIComponent(window.location.href);
window.open(
`https://twitter.com/intent/tweet?text=${text}&url=${url}`,
"_blank",
);
};
// Render blog post with full metadata
return (
<div className="post-page">
<nav className="post-nav">
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Copy page dropdown for sharing */}
<CopyPageDropdown
title={post.title}
content={post.content}
url={window.location.href}
/>
</nav>
<article className="post-article">
<header className="post-header">
<h1 className="post-title">{post.title}</h1>
<div className="post-meta-header">
<time className="post-date">
{format(parseISO(post.date), "MMMM yyyy")}
</time>
{post.readTime && (
<>
<span className="post-meta-separator">·</span>
<span className="post-read-time">{post.readTime}</span>
</>
)}
</div>
{post.description && (
<p className="post-description">{post.description}</p>
)}
</header>
<BlogPost content={post.content} />
<footer className="post-footer">
<div className="post-share">
<button
onClick={handleCopyLink}
className="share-button"
aria-label="Copy link"
>
<LinkIcon size={16} />
<span>{copied ? "Copied!" : "Copy link"}</span>
</button>
<button
onClick={handleShareTwitter}
className="share-button"
aria-label="Share on Twitter"
>
<Twitter size={16} />
<span>Tweet</span>
</button>
<a
href="/rss.xml"
target="_blank"
rel="noopener noreferrer"
className="share-button"
aria-label="RSS Feed"
>
<Rss size={16} />
<span>RSS</span>
</a>
</div>
{post.tags && post.tags.length > 0 && (
<div className="post-tags">
{post.tags.map((tag) => (
<span key={tag} className="post-tag">
{tag}
</span>
))}
</div>
)}
</footer>
</article>
</div>
);
}

947
src/styles/global.css Normal file
View File

@@ -0,0 +1,947 @@
/* CSS Variables for theming
THEME SWITCHER: Default theme is "tan". To change default, edit src/context/ThemeContext.tsx
Available themes: dark, light, tan, cloud */
:root[data-theme="dark"] {
--bg-primary: #111111;
--bg-secondary: #1a1a1a;
--bg-hover: #252525;
--text-primary: #fafafa;
--text-secondary: #a1a1a1;
--text-muted: #6b6b6b;
--border-color: #2a2a2a;
--accent: #fafafa;
--accent-hover: #e5e5e5;
--link-color: #fafafa;
--link-hover: #a1a1a1;
--code-bg: #1e1e1e;
--code-border: #2a2a2a;
--inline-code-bg: #2a2a2a;
--blockquote-border: #3a3a3a;
--blockquote-bg: #1a1a1a;
}
:root[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-hover: #f5f5f5;
--text-primary: #111111;
--text-secondary: #6b6b6b;
--text-muted: #a1a1a1;
--border-color: #e5e5e5;
--accent: #111111;
--accent-hover: #333333;
--link-color: #111111;
--link-hover: #6b6b6b;
--code-bg: #f5f5f5;
--code-border: #e5e5e5;
--inline-code-bg: #f0f0f0;
--blockquote-border: #e5e5e5;
--blockquote-bg: #fafafa;
}
:root[data-theme="tan"] {
--bg-primary: #faf8f5;
--bg-secondary: #f5f3f0;
--bg-hover: #ebe9e6;
--text-primary: #1a1a1a;
--text-secondary: #6b6b6b;
--text-muted: #999999;
--border-color: #e6e4e1;
--accent: #8b7355;
--accent-hover: #735f47;
--link-color: #8b7355;
--link-hover: #6b5a45;
--code-bg: #f0ece4;
--code-border: #e6e4e1;
--inline-code-bg: #ebe7df;
--blockquote-border: #d4cfc6;
--blockquote-bg: #f5f3f0;
}
/* Cloud theme - soft gray aesthetic */
:root[data-theme="cloud"] {
--bg-primary: #f5f5f5;
--bg-secondary: #ebebeb;
--bg-hover: #e0e0e0;
--text-primary: #171717;
--text-secondary: #525252;
--text-muted: #737373;
--border-color: #d4d4d4;
--accent: #171717;
--accent-hover: #404040;
--link-color: #171717;
--link-hover: #525252;
--code-bg: #e5e5e5;
--code-border: #d4d4d4;
--inline-code-bg: #e0e0e0;
--blockquote-border: #a3a3a3;
--blockquote-bg: #ebebeb;
}
/* Reset and base styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
/* FONT SWITCHER: Replace the font-family below to change fonts
Sans-serif (default): -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif
Use "New York" for a serif font.
Serif (New York): "New York", -apple-system-ui-serif, ui-serif, Georgia, Cambria, "Times New Roman", Times, serif */
font-family:
"New York",
-apple-system-ui-serif,
ui-serif,
Georgia,
Cambria,
"Times New Roman",
Times,
serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
/* Layout */
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
max-width: 680px;
width: 100%;
margin: 0 auto;
padding: 40px 24px;
}
/* Top navigation bar */
.top-nav {
position: fixed;
top: 24px;
right: 24px;
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
}
/* Page navigation links (About, Projects, Contact, etc.) */
.page-nav {
display: flex;
align-items: center;
gap: 16px;
}
.page-nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
transition: color 0.2s ease;
}
.page-nav-link:hover {
color: var(--text-primary);
}
.theme-toggle-container {
display: flex;
align-items: center;
}
.theme-toggle {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.theme-toggle:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
/* Home page styles */
.home {
padding-top: 10px;
}
.home-header {
margin-bottom: 40px;
}
/* Site logo on homepage */
.home-logo {
width: 48px;
height: 48px;
margin-bottom: 10px;
border-radius: 8px;
}
.home-name {
font-size: 32px;
font-weight: 400;
margin-bottom: 24px;
letter-spacing: -0.02em;
}
.home-intro,
.home-bio {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 16px;
line-height: 1.7;
}
.home-featured {
margin: 24px 0;
}
.home-featured-intro {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.home-featured-list {
list-style: none;
margin-left: 8px;
}
.home-featured-list li {
position: relative;
padding-left: 16px;
margin-bottom: 6px;
}
.home-featured-list li::before {
content: "•";
position: absolute;
left: 0;
color: var(--text-muted);
}
.home-featured-link {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 3px;
transition: color 0.2s ease;
}
.home-featured-link:hover {
color: var(--text-secondary);
}
.home-cta {
font-size: 16px;
color: var(--text-secondary);
margin-top: 20px;
}
.home-text-link {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 3px;
transition: color 0.2s ease;
}
.home-text-link:hover {
color: var(--text-secondary);
}
.external-icon {
display: inline-block;
vertical-align: middle;
margin-left: 2px;
opacity: 0.7;
}
.home-divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 40px 0;
}
/* Post list styles */
.post-list {
margin-top: 20px;
}
.post-year-group {
margin-bottom: 24px;
}
.year-heading {
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.posts {
list-style: none;
}
.post-item {
margin-bottom: 4px;
}
.post-link {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
color: var(--text-primary);
text-decoration: none;
transition: opacity 0.2s ease;
gap: 12px;
}
.post-link:hover {
opacity: 0.7;
}
/* Post list item title (on home page) */
.post-link .post-title {
font-size: 16px;
font-weight: 400;
flex: 1;
min-width: 0;
}
.post-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
}
.post-read-time {
color: var(--text-muted);
}
.post-date {
color: var(--text-muted);
min-width: 80px;
text-align: right;
}
/* Footer styles */
.home-footer {
padding: 20px 0;
}
.social-links {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.social-link {
color: var(--text-muted);
transition: color 0.2s ease;
}
.social-link:hover {
color: var(--text-primary);
}
/* Post page styles */
.post-page {
padding-top: 20px;
}
.post-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
}
/* Copy Page Dropdown Styles */
.copy-page-dropdown {
position: relative;
}
.copy-page-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.copy-page-trigger:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
border-color: var(--text-muted);
}
.dropdown-chevron {
transition: transform 0.15s ease;
}
.dropdown-chevron.open {
transform: rotate(180deg);
}
.copy-page-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 280px;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 1000;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.copy-page-item {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 12px 14px;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
transition: background-color 0.1s ease;
}
.copy-page-item:hover {
background-color: var(--bg-hover);
}
.copy-page-item:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
.copy-page-icon {
color: var(--text-muted);
flex-shrink: 0;
margin-top: 2px;
}
.copy-page-item:hover .copy-page-icon {
color: var(--text-secondary);
}
.copy-page-item-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.copy-page-item-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
}
.external-arrow {
font-size: 12px;
color: var(--text-muted);
}
.copy-page-item-desc {
font-size: 12px;
color: var(--text-muted);
}
.back-button {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
background: transparent;
border: none;
font-size: 14px;
cursor: pointer;
padding: 8px 12px;
margin-left: -12px;
border-radius: 6px;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.back-button:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.post-article {
max-width: 100%;
}
.post-header {
margin-bottom: 48px;
}
/* Post page article title */
.post-header .post-title {
font-size: 24px;
font-weight: 300;
letter-spacing: -0.02em;
margin-bottom: 16px;
line-height: 1.2;
}
.post-meta-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-muted);
}
.post-meta-separator {
color: var(--text-muted);
}
.post-description {
font-size: 18px;
color: var(--text-secondary);
margin-top: 20px;
line-height: 1.6;
}
/* Blog post content styles */
.blog-post-content {
font-size: 17px;
line-height: 1.8;
color: var(--text-primary);
}
.blog-post-content p {
margin-bottom: 24px;
}
.blog-h1 {
font-size: 24px;
font-weight: 300;
margin: 48px 0 24px;
letter-spacing: -0.02em;
line-height: 1.3;
}
.blog-h2 {
font-size: 20px;
font-weight: 300;
margin: 40px 0 20px;
letter-spacing: -0.01em;
line-height: 1.3;
}
.blog-h3 {
font-size: 18px;
font-weight: 300;
margin: 32px 0 16px;
line-height: 1.4;
}
.blog-h4 {
font-size: 16px;
font-weight: 300;
margin: 24px 0 12px;
line-height: 1.4;
}
.blog-h5 {
font-size: 14px;
font-weight: 300;
margin: 20px 0 10px;
line-height: 1.4;
}
.blog-link {
color: var(--link-color);
text-decoration: underline;
text-underline-offset: 3px;
transition: color 0.2s ease;
}
.blog-link:hover {
color: var(--link-hover);
}
.blog-ul,
.blog-ol {
margin: 0 0 24px 24px;
}
.blog-li {
margin-bottom: 8px;
}
.blog-li::marker {
color: var(--text-muted);
}
.blog-blockquote {
border-left: 3px solid var(--blockquote-border);
background-color: var(--blockquote-bg);
padding: 16px 20px;
margin: 24px 0;
border-radius: 0 6px 6px 0;
}
.blog-blockquote p {
margin-bottom: 0;
color: var(--text-secondary);
font-style: italic;
}
.blog-hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 48px 0;
}
/* Code styles */
.code-block-wrapper {
position: relative;
margin: 24px 0;
}
.code-language {
position: absolute;
top: 8px;
right: 44px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
z-index: 1;
}
/* Copy button for code blocks */
.code-copy-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition:
opacity 0.15s ease,
background 0.15s ease,
color 0.15s ease;
}
.code-block-wrapper:hover .code-copy-button {
opacity: 1;
}
.code-copy-button:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--text-muted);
}
.code-copy-button:active {
transform: scale(0.95);
}
.inline-code {
background-color: var(--inline-code-bg);
padding: 2px 6px;
border-radius: 4px;
font-family:
SF Mono,
Monaco,
Cascadia Code,
Roboto Mono,
Consolas,
monospace;
font-size: 0.9em;
}
/* Image styles */
.blog-image-wrapper {
display: block;
margin: 32px 0;
}
.blog-image {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.blog-image-caption {
display: block;
font-size: 14px;
color: var(--text-muted);
text-align: center;
margin-top: 12px;
}
/* Post footer styles */
.post-footer {
margin-top: 64px;
padding-top: 32px;
border-top: 1px solid var(--border-color);
}
.post-share {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.share-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.share-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.post-tag {
font-size: 13px;
color: var(--text-muted);
background-color: var(--bg-secondary);
padding: 4px 12px;
border-radius: 16px;
}
/* Loading and error states */
.loading,
.no-posts,
.post-loading,
.post-not-found {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.post-not-found h1 {
font-size: 24px;
margin-bottom: 16px;
color: var(--text-primary);
}
.post-not-found p {
margin-bottom: 24px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 3px;
}
/* Footer section */
.home-footer {
margin-top: 4rem;
padding-top: 2rem;
}
.home-footer-text {
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.7;
}
.home-footer-text a {
color: var(--link-color);
text-decoration: none;
}
.home-footer-text a:hover {
color: var(--link-hover);
text-decoration: underline;
}
/* Responsive styles */
@media (max-width: 768px) {
.main-content {
padding: 24px 16px;
}
.top-nav {
top: 16px;
right: 16px;
gap: 12px;
}
.page-nav {
gap: 12px;
}
.page-nav-link {
font-size: 13px;
}
.home-name {
font-size: 28px;
}
.post-link .post-title {
font-size: 18px;
font-weight: 400;
}
.post-header .post-title {
font-size: 28px;
}
.post-link {
flex-direction: column;
gap: 4px;
}
.post-meta {
font-size: 13px;
}
.blog-post-content {
font-size: 16px;
}
.blog-h1 {
font-size: 22px;
}
.blog-h2 {
font-size: 18px;
}
.blog-h3 {
font-size: 16px;
}
.blog-h4 {
font-size: 15px;
}
.blog-h5 {
font-size: 13px;
}
/* Copy page dropdown mobile */
.copy-page-menu {
width: 260px;
right: -8px;
}
}
/* Dark mode shadow adjustment */
:root[data-theme="dark"] .copy-page-menu {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
/* Tan theme shadow adjustment */
:root[data-theme="tan"] .copy-page-menu {
box-shadow: 0 4px 16px rgba(139, 115, 85, 0.12);
}
/* Selection styles */
::selection {
background-color: var(--accent);
color: var(--bg-primary);
}
/* Scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CONVEX_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "scripts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
},
});