docs: add changelog entries for v1.33.1 through v1.37.0

Add missing changelog entries to content/pages/changelog-page.md:

v1.34.0 (2025-12-26): Blog page featured layout with hero post
- blogFeatured frontmatter field for posts
- Hero card displays first featured post with landscape image
- 2-column featured row for remaining featured posts
- 3-column grid for regular posts

v1.35.0 (2025-12-26): Image support at top of posts and pages
- showImageAtTop frontmatter field
- Full-width image display above post header
- Works for both posts and pages

v1.36.0 (2025-12-27): Social footer component
- Customizable social links (8 platform types)
- Copyright with auto-updating year
- showSocialFooter frontmatter field for per-page control
- Configurable via siteConfig.socialFooter

v1.37.0 (2025-12-27): Newsletter Admin UI
- Three-column admin interface at /newsletter-admin
- Subscriber management with search and filters
- Send newsletter panel (post selection or custom email)
- Weekly digest automation (Sunday 9am UTC)
- Developer notifications (subscriber alerts, weekly stats)
- Markdown-to-HTML conversion for custom emails
This commit is contained in:
Wayne Sutton
2025-12-27 15:32:07 -08:00
parent c312a4c808
commit a87db9d171
55 changed files with 7753 additions and 1260 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -481,6 +481,169 @@ homepage: {
---
## Newsletter Configuration
The newsletter feature integrates with AgentMail for email subscriptions and sending. It is disabled by default.
### Environment Variables
Set these in the Convex dashboard:
| Variable | Description |
| -------- | ----------- |
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `newsletter@mail.agentmail.to`) |
### In fork-config.json
```json
{
"newsletter": {
"enabled": true,
"agentmail": {
"inbox": "newsletter@mail.agentmail.to"
},
"signup": {
"home": {
"enabled": true,
"position": "above-footer",
"title": "Stay Updated",
"description": "Get new posts delivered to your inbox."
},
"blogPage": {
"enabled": true,
"position": "above-footer",
"title": "Subscribe",
"description": "Get notified when new posts are published."
},
"posts": {
"enabled": true,
"position": "below-content",
"title": "Enjoyed this post?",
"description": "Subscribe for more updates."
}
}
}
}
```
### Manual Configuration
In `src/config/siteConfig.ts`:
```typescript
newsletter: {
enabled: true, // Master switch for newsletter feature
agentmail: {
inbox: "newsletter@mail.agentmail.to",
},
signup: {
home: {
enabled: true,
position: "above-footer", // or "below-intro"
title: "Stay Updated",
description: "Get new posts delivered to your inbox.",
},
blogPage: {
enabled: true,
position: "above-footer", // or "below-posts"
title: "Subscribe",
description: "Get notified when new posts are published.",
},
posts: {
enabled: true,
position: "below-content",
title: "Enjoyed this post?",
description: "Subscribe for more updates.",
},
},
},
```
### Frontmatter Override
Hide or show newsletter signup on specific posts using frontmatter:
```yaml
---
title: My Post
newsletter: false # Hide newsletter signup on this post
---
```
Or force show it even if posts default is disabled:
```yaml
---
title: Special Offer Post
newsletter: true # Show newsletter signup on this post
---
```
### Sending Newsletters
To send a newsletter for a specific post:
```bash
npm run newsletter:send setup-guide
```
Or use the Convex CLI directly:
```bash
npx convex run newsletter:sendPostNewsletter '{"postSlug":"setup-guide","siteUrl":"https://yoursite.com","siteName":"Your Site"}'
```
### Subscriber Management
View subscriber count on the `/stats` page. Subscribers are stored in the `newsletterSubscribers` table in Convex.
---
## Contact Form Configuration
Enable contact forms on any page or post via frontmatter. Messages are sent via AgentMail.
### Environment Variables
Set these in the Convex dashboard:
| Variable | Description |
| -------- | ----------- |
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your inbox address for sending (e.g., `newsletter@mail.agentmail.to`) |
| `AGENTMAIL_CONTACT_EMAIL` | Optional: recipient for contact form messages (defaults to AGENTMAIL_INBOX) |
### Site Config
In `src/config/siteConfig.ts`:
```typescript
contactForm: {
enabled: true, // Global toggle for contact form feature
title: "Get in Touch",
description: "Send us a message and we'll get back to you.",
},
```
**Note:** Recipient email is configured via Convex environment variables (`AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX`). Never hardcode email addresses in code.
### Frontmatter Usage
Enable contact form on any page or post:
```yaml
---
title: Contact Us
slug: contact
contactForm: true
---
```
The form includes name, email, and message fields. Submissions are stored in Convex and sent via AgentMail to the configured recipient.
---
## AI Agent Prompt
Copy this prompt to have an AI agent apply all changes:

11
TASK.md
View File

@@ -7,10 +7,19 @@
## Current Status
v1.35.0 ready. Added `showImageAtTop` frontmatter field to display images at the top of posts and pages above the header. Image appears full-width when enabled, otherwise only used for Open Graph and featured cards.
v1.38.0 ready. Improved newsletter CLI commands - `newsletter:send` now calls mutation directly and added `newsletter:send:stats` for sending weekly stats summary. Created blog post "How to use AgentMail with Markdown Sync" with complete setup guide.
## Completed
- [x] Newsletter CLI improvements
- [x] Updated newsletter:send to call scheduleSendPostNewsletter mutation directly
- [x] Added newsletter:send:stats command for weekly stats summary
- [x] Created scheduleSendStatsSummary mutation in convex/newsletter.ts
- [x] Created send-newsletter-stats.ts script
- [x] Verified all AgentMail features use environment variables (no hardcoded emails)
- [x] Updated documentation (docs.md, files.md, changelog.md, changelog-page.md, TASK.md)
- [x] Created blog post "How to use AgentMail with Markdown Sync"
- [x] showImageAtTop frontmatter field for posts and pages
- [x] Added showImageAtTop optional boolean field to convex/schema.ts for posts and pages
- [x] Updated scripts/sync-posts.ts to parse showImageAtTop from frontmatter

View File

@@ -4,6 +4,119 @@ 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.38.0] - 2025-12-27
### Added
- Newsletter CLI improvements
- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly
- New `newsletter:send:stats` command to send weekly stats summary
- Both commands provide clear success/error feedback
- New mutation `scheduleSendStatsSummary` for CLI stats sending
- Blog post: "How to use AgentMail with Markdown Sync" with complete setup guide
### Changed
- `scripts/send-newsletter.ts`: Now calls mutation directly instead of printing instructions
- `convex/newsletter.ts`: Added `scheduleSendStatsSummary` mutation
### Technical
- New script: `scripts/send-newsletter-stats.ts`
- All AgentMail features verified to use environment variables (no hardcoded emails)
## [1.37.0] - 2025-12-27
### Added
- Newsletter Admin UI at `/newsletter-admin`
- Three-column layout similar to Write page
- View all subscribers with search and filter (all/active/unsubscribed)
- Stats showing active, total, and sent newsletter counts
- Delete subscribers directly from admin
- Send newsletter panel with two modes:
- Send Post: Select a blog post to send as newsletter
- Write Email: Compose custom email with markdown support
- Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists)
- Copy icon on success messages to copy CLI commands
- Theme-aware success/error styling (no hardcoded green)
- Recent newsletters list showing sent history
- Configurable via `siteConfig.newsletterAdmin`
- Weekly Digest automation
- Cron job runs every Sunday at 9:00 AM UTC
- Automatically sends all posts published in the last 7 days
- Uses AgentMail SDK for email delivery
- Configurable via `siteConfig.weeklyDigest`
- Developer Notifications
- New subscriber alerts sent via email when someone subscribes
- Weekly stats summary sent every Monday at 9:00 AM UTC
- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient
- Configurable via `siteConfig.newsletterNotifications`
- Admin queries and mutations for newsletter management
- `getAllSubscribers`: Paginated subscriber list with search/filter
- `deleteSubscriber`: Remove subscriber from database
- `getNewsletterStats`: Stats for admin dashboard
- `getPostsForNewsletter`: List of posts with sent status
### Changed
- `convex/newsletter.ts`: Added admin queries (getAllSubscribers, deleteSubscriber, getNewsletterStats, getPostsForNewsletter, getStatsForSummary) and scheduleSendCustomNewsletter mutation
- `convex/newsletterActions.ts`: Added sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary, sendCustomNewsletter actions with markdown-to-HTML conversion
- `convex/posts.ts`: Added getRecentPostsInternal query for weekly digest
- `convex/crons.ts`: Added weekly digest (Sunday 9am) and stats summary (Monday 9am) cron jobs
- `src/config/siteConfig.ts`: Added NewsletterAdminConfig, NewsletterNotificationsConfig, WeeklyDigestConfig interfaces
- `src/App.tsx`: Added /newsletter-admin route
- `src/styles/global.css`: Added newsletter admin styles with responsive design
### Technical
- New page: `src/pages/NewsletterAdmin.tsx`
- Newsletter admin hidden from navigation by default (security through obscurity)
- All admin features togglable via siteConfig
- Uses Convex internal actions for email sending (Node.js runtime with AgentMail SDK)
- Cron jobs use environment variables: SITE_URL, SITE_NAME
## [1.36.0] - 2025-12-27
### Added
- Social footer component with customizable social links and copyright
- Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more)
- Shows copyright symbol, site name, and auto-updating year on the right
- Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts`
- Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website
- Uses Phosphor icons for consistent styling
- Appears below the main footer on homepage, blog posts, and pages
- Can work independently of the main footer when set via frontmatter
- Frontmatter control for social footer visibility
- `showSocialFooter` field for posts and pages to override siteConfig defaults
- Set `showSocialFooter: false` to hide on specific posts/pages
- Works like existing `showFooter` field pattern
- Social footer configuration options
- `enabled`: Global toggle for social footer
- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility
- `socialLinks`: Array of social link objects with platform and URL
- `copyright.siteName`: Site/company name for copyright display
- `copyright.showYear`: Toggle for auto-updating year
### Changed
- `src/config/siteConfig.ts`: Added `SocialLink`, `SocialFooterConfig` interfaces and `socialFooter` configuration
- `convex/schema.ts`: Added `showSocialFooter` optional boolean field to posts and pages tables
- `convex/posts.ts` and `convex/pages.ts`: Updated queries and mutations to include `showSocialFooter` field
- `scripts/sync-posts.ts`: Updated to parse `showSocialFooter` from frontmatter for both posts and pages
- `src/pages/Home.tsx`: Added SocialFooter component below Footer
- `src/pages/Post.tsx`: Added SocialFooter component below Footer for both posts and pages
- `src/pages/Blog.tsx`: Added SocialFooter component below Footer
- `src/styles/global.css`: Added social footer styles with flexbox layout and mobile responsive design
### Technical
- New component: `src/components/SocialFooter.tsx`
- Uses Phosphor icons: GithubLogo, TwitterLogo, LinkedinLogo, InstagramLogo, YoutubeLogo, TiktokLogo, DiscordLogo, Globe
- Responsive design: stacks vertically on mobile (max-width: 480px)
- Year automatically updates using `new Date().getFullYear()`
## [1.35.0] - 2025-12-26
### Added

View File

@@ -0,0 +1,240 @@
---
title: "How to use AgentMail with Markdown Sync"
description: "Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog"
date: "2025-12-27"
slug: "how-to-use-agentmail"
published: true
tags: ["agentmail", "newsletter", "email", "setup"]
---
AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage.
## What is AgentMail
AgentMail is an email service designed for AI agents and developers. It handles email sending and receiving without OAuth or MFA requirements, making it ideal for automated workflows.
For this markdown blog framework, AgentMail powers:
- Newsletter subscriptions and sending
- Contact forms on posts and pages
- Developer notifications for new subscribers
- Weekly digest emails
- Weekly stats summaries
## Setup
### 1. Create an AgentMail account
Sign up at [agentmail.to](https://agentmail.to) and create an inbox. Your inbox address will look like `yourname@agentmail.to`.
### 2. Get your API key
In the AgentMail dashboard, navigate to API settings and copy your API key. You'll need this for Convex environment variables.
### 3. Configure Convex environment variables
In your Convex dashboard, go to Settings > Environment Variables and add:
| Variable | Description | Required |
|----------|-------------|----------|
| `AGENTMAIL_API_KEY` | Your AgentMail API key | Yes |
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `markdown@agentmail.to`) | Yes |
| `AGENTMAIL_CONTACT_EMAIL` | Contact form recipient (defaults to inbox if not set) | No |
**Important:** Never hardcode email addresses in your code. Always use environment variables.
### 4. Enable features in siteConfig
Edit `src/config/siteConfig.ts` to enable newsletter and contact form features:
```typescript
newsletter: {
enabled: true,
showOnHomepage: true,
showOnBlogPage: true,
showOnPosts: true,
title: "Subscribe to the newsletter",
description: "Get updates delivered to your inbox",
},
contactForm: {
enabled: true,
title: "Get in touch",
description: "Send us a message",
},
```
## Newsletter features
### Subscriber management
The Newsletter Admin page at `/newsletter-admin` provides:
- View all subscribers with search and filters
- Delete subscribers
- Send blog posts as newsletters
- Write and send custom emails with markdown support
- View email statistics dashboard
- Track recent sends (last 10)
### Sending newsletters
**Via CLI:**
```bash
# Send a specific post to all subscribers
npm run newsletter:send setup-guide
# Send weekly stats summary to your inbox
npm run newsletter:send:stats
```
**Via Admin UI:**
1. Navigate to `/newsletter-admin`
2. Select "Send Post" or "Write Email" from the sidebar
3. Choose a post or compose a custom email
4. Click "Send Newsletter"
### Weekly digest
Automated weekly digest emails are sent every Sunday at 9:00 AM UTC. They include all posts published in the last 7 days.
Configure in `siteConfig.ts`:
```typescript
weeklyDigest: {
enabled: true,
},
```
### Developer notifications
Receive email notifications when:
- A new subscriber signs up
- Weekly stats summary (every Monday at 9:00 AM UTC)
Configure in `siteConfig.ts`:
```typescript
newsletterNotifications: {
enabled: true,
},
```
Notifications are sent to `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` if contact email is not set.
## Contact forms
### Enable on posts and pages
Add `contactForm: true` to any post or page frontmatter:
```markdown
---
title: "Contact Us"
slug: "contact"
published: true
contactForm: true
---
Your page content here...
```
The contact form includes:
- Name field
- Email field
- Message field
Submissions are stored in Convex and sent via AgentMail to your configured recipient.
### Frontmatter options
| Field | Type | Description |
|-------|------|-------------|
| `contactForm` | boolean | Enable contact form on this post/page |
## Frontmatter options
### Newsletter signup
Control newsletter signup display per post/page:
```markdown
---
title: "My Post"
newsletter: true # Show signup (default: follows siteConfig)
---
```
Or hide it:
```markdown
---
title: "My Post"
newsletter: false # Hide signup even if enabled globally
---
```
## Environment variables
All AgentMail features require these Convex environment variables:
**Required:**
- `AGENTMAIL_API_KEY` - Your AgentMail API key
- `AGENTMAIL_INBOX` - Your inbox address
**Optional:**
- `AGENTMAIL_CONTACT_EMAIL` - Contact form recipient (defaults to inbox)
**Note:** If environment variables are not configured, users will see: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
## CLI commands
| Command | Description |
|---------|-------------|
| `npm run newsletter:send <slug>` | Send a blog post to all subscribers |
| `npm run newsletter:send:stats` | Send weekly stats summary to your inbox |
## Troubleshooting
**Emails not sending:**
1. Verify `AGENTMAIL_API_KEY` and `AGENTMAIL_INBOX` are set in Convex dashboard
2. Check Convex function logs for error messages
3. Ensure your inbox is active in AgentMail dashboard
**Contact form not appearing:**
1. Verify `contactForm: true` is in frontmatter
2. Check `siteConfig.contactForm.enabled` is `true`
3. Run `npm run sync` to sync frontmatter changes
**Newsletter Admin not accessible:**
1. Verify `siteConfig.newsletterAdmin.enabled` is `true`
2. Navigate to `/newsletter-admin` directly (hidden from nav by default)
## Resources
- [AgentMail Documentation](https://docs.agentmail.to)
- [AgentMail Quickstart](https://docs.agentmail.to/quickstart)
- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email)
- [AgentMail Inboxes](https://docs.agentmail.to/inboxes)
## Summary
AgentMail integration provides:
- Newsletter subscriptions and sending
- Contact forms on any post or page
- Automated weekly digests
- Developer notifications
- Admin UI for subscriber management
- CLI tools for sending newsletters and stats
All features use Convex environment variables for configuration. No hardcoded emails in your codebase.

View File

@@ -0,0 +1,64 @@
---
title: "How to use Firecrawl"
description: "Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat."
date: "2025-01-20"
slug: "how-to-use-firecrawl"
published: true
tags: ["tutorial", "firecrawl", "import"]
---
# How to use Firecrawl
You found an article you want to republish or reference. Copying content manually takes time. Firecrawl scrapes web pages and converts them to markdown automatically.
## What it is
Firecrawl is a web scraping service that turns any URL into clean markdown. This app uses it in two places: the import script for creating draft posts, and the AI chat feature for fetching page content.
## Who it's for
Developers who want to import external articles without manual copying. If you republish content or need to reference external sources, Firecrawl saves time.
## The problem it solves
Manually copying content from websites is slow. You copy text, fix formatting, add frontmatter, and handle images. Firecrawl does this automatically.
## How it works
The import script scrapes a URL, extracts the title and description, converts HTML to markdown, and creates a draft post in `content/blog/`. The AI chat feature uses Firecrawl to fetch page content when you share URLs in conversations.
## How to try it
**Step 1: Get your API key**
Visit [firecrawl.dev](https://firecrawl.dev) and sign up. Copy your API key. It starts with `fc-`.
**Step 2: Set up local imports**
Add the key to `.env.local` in your project root:
```
FIRECRAWL_API_KEY=fc-your-api-key-here
```
Now you can import articles:
```bash
npm run import https://example.com/article
```
This creates a draft post in `content/blog/`. Review it, set `published: true`, then run `npm run sync`.
**Step 3: Enable AI chat scraping**
If you use the AI chat feature, set the same key in your Convex Dashboard:
1. Go to [dashboard.convex.dev](https://dashboard.convex.dev)
2. Select your project
3. Open Settings > Environment Variables
4. Add `FIRECRAWL_API_KEY` with your key value
5. Deploy: `npx convex deploy`
The AI chat can now fetch content from URLs you share.
That's it. One API key, two places to set it, and you're done.

View File

@@ -8,6 +8,7 @@ tags: ["convex", "netlify", "tutorial", "deployment"]
readTime: "8 min read"
featured: true
featuredOrder: 6
newsletter: true
layout: "sidebar"
image: "/images/setupguide.png"
authorName: "Markdown"
@@ -62,6 +63,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Visitor Map](#visitor-map)
- [Logo Gallery](#logo-gallery)
- [Blog page](#blog-page)
- [Hardcoded Navigation Items](#hardcoded-navigation-items)
- [Scroll-to-top button](#scroll-to-top-button)
- [Change the Default Theme](#change-the-default-theme)
- [Change the Font](#change-the-font)
@@ -83,6 +85,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Build failures on Netlify](#build-failures-on-netlify)
- [Project Structure](#project-structure)
- [Write Page](#write-page)
- [AI Agent chat](#ai-agent-chat)
- [Next Steps](#next-steps)
## Prerequisites
@@ -187,6 +190,7 @@ export default defineSchema({
Blog posts live in `content/blog/` as markdown files. Sync them to Convex:
**Development:**
```bash
npm run sync # Sync markdown content
npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt)
@@ -194,6 +198,7 @@ npm run sync:all # Sync content + discovery files together
```
**Production:**
```bash
npm run sync:prod # Sync markdown content
npm run sync:discovery:prod # Update discovery files
@@ -331,7 +336,7 @@ 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) |
@@ -409,6 +414,7 @@ The `npm run sync` command only syncs markdown text content. Images are deployed
After adding or editing posts, sync to Convex.
**Development sync:**
```bash
npm run sync # Sync markdown content
npm run sync:discovery # Update discovery files
@@ -426,6 +432,7 @@ 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 # Sync markdown content
npm run sync:discovery:prod # Update discovery files
@@ -444,7 +451,7 @@ Both files are gitignored. Each developer creates their own local environment fi
### When to Sync vs Deploy
| What you're changing | Command | Timing |
| -------------------------------- | -------------------------- | -------------------- |
| -------------------------------- | -------------------------- | ----------------------- |
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
@@ -971,15 +978,12 @@ body {
serif;
/* Monospace */
font-family:
"IBM Plex Mono",
"Liberation Mono",
ui-monospace,
monospace;
font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace;
}
```
Available font options:
- `serif`: New York serif font (default)
- `sans`: System sans-serif fonts
- `monospace`: IBM Plex Mono monospace font
@@ -1103,6 +1107,49 @@ How it works:
- A cron job cleans up stale sessions every 5 minutes
- No personal data is stored (only anonymous UUIDs)
## Newsletter Admin
A newsletter management interface is available at `/newsletter-admin`. Use it to view subscribers, send newsletters, and compose custom emails.
**Features:**
- View and search all subscribers with filtering options (search bar in header)
- Delete subscribers from the admin UI
- Send published blog posts as newsletters
- Write custom emails using markdown formatting
- View recent newsletter sends (last 10, tracks both posts and custom emails)
- Email statistics dashboard with comprehensive metrics
**Setup:**
1. Enable in `src/config/siteConfig.ts`:
```typescript
newsletterAdmin: {
enabled: true,
showInNav: false, // Keep hidden, access via direct URL
},
```
2. Set environment variables in Convex Dashboard:
| Variable | Description |
| ------------------------- | ------------------------------------ |
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your AgentMail inbox address |
| `AGENTMAIL_CONTACT_EMAIL` | Optional recipient for contact forms |
**Important:** If environment variables are not configured, users will see an error message when attempting to use newsletter or contact form features: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
**Sending newsletters:**
Two modes are available:
1. **Send Post**: Select a blog post to send to all active subscribers
2. **Write Email**: Compose custom content with markdown support
The admin UI shows send results and provides CLI commands as alternatives.
## Mobile Navigation
On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route.
@@ -1112,7 +1159,7 @@ On mobile and tablet screens (under 768px), a hamburger menu provides navigation
Each post and page includes a share dropdown with options for AI tools:
| Option | Description |
| -------------------- | ------------------------------------------------- |
| -------------------- | ------------------------------------------ |
| Copy page | Copies formatted markdown to clipboard |
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
| Open in Claude | Opens Claude with raw markdown URL |
@@ -1123,7 +1170,7 @@ Each post and page includes a share dropdown with options for AI tools:
**Git push required for AI links:** The "Open in ChatGPT," "Open in Claude," and "Open in Perplexity" options use GitHub raw URLs to fetch content. For these to work, your content must be pushed to GitHub with `git push`. The `npm run sync` command syncs content to Convex for your live site, but AI services fetch directly from GitHub.
| What you want | Command needed |
| ------------------------------------ | ------------------------------ |
| ------------------------------------ | ------------------------------------------------- |
| Content visible on your site | `npm run sync` or `sync:prod` |
| Discovery files updated | `npm run sync:discovery` or `sync:discovery:prod` |
| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub |

View File

@@ -9,6 +9,161 @@ layout: "sidebar"
All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v1.38.0
Released December 27, 2025
**Newsletter CLI improvements**
- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly
- Sends emails in the background instead of printing instructions
- Provides clear success/error feedback
- Shows helpful messages about checking Newsletter Admin for results
- New `newsletter:send:stats` command
- Sends weekly stats summary to your inbox on demand
- Uses `scheduleSendStatsSummary` mutation
- Email sent to AGENTMAIL_INBOX or AGENTMAIL_CONTACT_EMAIL
- New mutation `scheduleSendStatsSummary` in `convex/newsletter.ts`
- Allows CLI to trigger stats summary sending
- Schedules `sendWeeklyStatsSummary` internal action
**Documentation**
- Blog post: "How to use AgentMail with Markdown Sync"
- Complete setup guide for AgentMail integration
- Environment variables configuration
- Newsletter and contact form features
- CLI commands documentation
- Troubleshooting section
- Updated docs.md with new CLI commands
- Updated files.md with new script reference
- Verified all AgentMail features use environment variables (no hardcoded emails)
Updated files: `scripts/send-newsletter.ts`, `scripts/send-newsletter-stats.ts`, `convex/newsletter.ts`, `package.json`, `content/blog/how-to-use-agentmail.md`, `content/pages/docs.md`, `files.md`, `changelog.md`, `content/pages/changelog-page.md`, `TASK.md`
## v1.37.0
Released December 27, 2025
**Newsletter Admin UI**
- Newsletter Admin UI at `/newsletter-admin`
- Three-column layout similar to Write page
- View all subscribers with search and filter (all/active/unsubscribed)
- Stats showing active, total, and sent newsletter counts
- Delete subscribers directly from admin
- Send newsletter panel with two modes:
- Send Post: Select a blog post to send as newsletter
- Write Email: Compose custom email with markdown support
- Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists)
- Copy icon on success messages to copy CLI commands
- Theme-aware success/error styling (no hardcoded green)
- Recent newsletters list showing sent history
- Configurable via `siteConfig.newsletterAdmin`
**Weekly Digest automation**
- Cron job runs every Sunday at 9:00 AM UTC
- Automatically sends all posts published in the last 7 days
- Uses AgentMail SDK for email delivery
- Configurable via `siteConfig.weeklyDigest`
**Developer Notifications**
- New subscriber alerts sent via email when someone subscribes
- Weekly stats summary sent every Monday at 9:00 AM UTC
- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient
- Configurable via `siteConfig.newsletterNotifications`
**Admin queries and mutations**
- `getAllSubscribers`: Paginated subscriber list with search/filter
- `deleteSubscriber`: Remove subscriber from database
- `getNewsletterStats`: Stats for admin dashboard
- `getPostsForNewsletter`: List of posts with sent status
Updated files: `convex/newsletter.ts`, `convex/newsletterActions.ts`, `convex/posts.ts`, `convex/crons.ts`, `src/config/siteConfig.ts`, `src/App.tsx`, `src/styles/global.css`, `src/pages/NewsletterAdmin.tsx`
## v1.36.0
Released December 27, 2025
**Social footer component**
- Social footer component with customizable social links and copyright
- Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more)
- Shows copyright symbol, site name, and auto-updating year on the right
- Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts`
- Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website
- Uses Phosphor icons for consistent styling
- Appears below the main footer on homepage, blog posts, and pages
- Can work independently of the main footer when set via frontmatter
**Frontmatter control for social footer**
- `showSocialFooter` field for posts and pages to override siteConfig defaults
- Set `showSocialFooter: false` to hide on specific posts/pages
- Works like existing `showFooter` field pattern
**Social footer configuration options**
- `enabled`: Global toggle for social footer
- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility
- `socialLinks`: Array of social link objects with platform and URL
- `copyright.siteName`: Site/company name for copyright display
- `copyright.showYear`: Toggle for auto-updating year
Updated files: `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/pages/Home.tsx`, `src/pages/Post.tsx`, `src/pages/Blog.tsx`, `src/styles/global.css`, `src/components/SocialFooter.tsx`
## v1.35.0
Released December 26, 2025
**Image support at top of posts and pages**
- `showImageAtTop` frontmatter field for posts and pages
- Set `showImageAtTop: true` to display the `image` field at the top of the post/page above the header
- Image displays full-width with rounded corners above the post header
- Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails
- Works for both blog posts and static pages
- Image appears above the post header when enabled
Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/pages/Write.tsx`, `src/styles/global.css`
Documentation updated: `content/pages/docs.md`, `content/blog/how-to-publish.md`, `content/blog/using-images-in-posts.md`, `files.md`
## v1.34.0
Released December 26, 2025
**Blog page featured layout with hero post**
- `blogFeatured` frontmatter field for posts to mark as featured on blog page
- First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link
- Remaining `blogFeatured` posts display in 2-column featured row with excerpts
- Regular (non-featured) posts display in 3-column grid without excerpts
- New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display
- New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date
- `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
- Card images use 16:10 landscape aspect ratio
- Footer support on blog page via `siteConfig.footer.showOnBlogPage`
Updated files: `convex/schema.ts`, `convex/posts.ts`, `scripts/sync-posts.ts`, `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/styles/global.css`
## v1.33.1
Released December 26, 2025
**Article centering in sidebar layouts**
- Article content now centers in the middle column when sidebars are present
- Left sidebar stays flush left, right sidebar stays flush right
- Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column
- Works with both two-column (left sidebar only) and three-column (both sidebars) layouts
- Consistent `max-width: 800px` for article content across all sidebar configurations
Updated files: `src/styles/global.css`
## v1.33.0
Released December 26, 2025

View File

@@ -2,42 +2,17 @@
title: "Contact"
slug: "contact"
published: true
contactForm: false
newsletter: false
order: 4
---
You found the contact page. Nice
<!-- contactform -->
## 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.

View File

@@ -6,7 +6,7 @@ order: 0
layout: "sidebar"
rightSidebar: true
aiChat: true
footer: true
showFooter: true
---
## Getting Started
@@ -103,7 +103,7 @@ Content here...
```
| Field | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `title` | Yes | Post title |
| `description` | Yes | SEO description |
| `date` | Yes | YYYY-MM-DD format |
@@ -119,6 +119,14 @@ Content here...
| `authorName` | No | Author display name shown next to date |
| `authorImage` | No | Round author avatar image URL |
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
| `showFooter` | No | Show footer on this post (overrides siteConfig default) |
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) |
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) |
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
| `contactForm` | No | Enable contact form on this post |
### Static pages
@@ -136,7 +144,7 @@ Content here...
```
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------- |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
@@ -151,6 +159,12 @@ Content here...
| `authorImage` | No | Round author avatar image URL |
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
| `showFooter` | No | Show footer on this page (overrides siteConfig default) |
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) |
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
| `contactForm` | No | Enable contact form on this page |
**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav.
@@ -803,11 +817,86 @@ The `/stats` page displays real-time analytics:
All stats update automatically via Convex subscriptions.
## Newsletter Admin
The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters.
**Features:**
- View and search all subscribers (search bar in header)
- Filter by status (all, active, unsubscribed)
- Delete subscribers
- Send blog posts as newsletters
- Write and send custom emails with markdown support
- View recent newsletter sends (last 10, includes both posts and custom emails)
- Email statistics dashboard with:
- Total emails sent
- Newsletters sent count
- Active subscribers
- Retention rate
- Detailed summary table
**Configuration:**
Enable in `src/config/siteConfig.ts`:
```typescript
newsletterAdmin: {
enabled: true, // Enable /newsletter-admin route
showInNav: false, // Hide from navigation (access via direct URL)
},
```
**Environment Variables (Convex):**
| Variable | Description |
| ------------------------- | --------------------------------------------------- |
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your AgentMail inbox (e.g., `inbox@agentmail.to`) |
| `AGENTMAIL_CONTACT_EMAIL` | Optional contact form recipient (defaults to inbox) |
**Note:** If environment variables are not configured, users will see the error message: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." when attempting to send newsletters or use contact forms.
**Sending Newsletters:**
The admin UI supports two sending modes:
1. **Send Post**: Select a published blog post to send as a newsletter
2. **Write Email**: Compose a custom email with markdown formatting
Custom emails support markdown syntax:
- `# Heading` for headers
- `**bold**` and `*italic*` for emphasis
- `[link text](url)` for links
- `- item` for bullet lists
**CLI Commands:**
You can send newsletters via command line:
```bash
# Send a blog post to all subscribers
npm run newsletter:send <post-slug>
# Send weekly stats summary to your inbox
npm run newsletter:send:stats
```
Example:
```bash
npm run newsletter:send setup-guide
```
The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results.
## API endpoints
| Endpoint | Description |
| ------------------------------ | --------------------------- |
| `/stats` | Real-time analytics |
| `/newsletter-admin` | Newsletter management UI |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |

View File

@@ -0,0 +1,30 @@
---
title: Newsletter
slug: newsletter
published: true
order: 15
showInNav: true
newsletter: true
---
# Newsletter
Stay updated with the latest posts and updates from the markdown sync framework.
## What you will get
When you subscribe, you will receive:
- Notifications when new blog posts are published
- Updates about new features and improvements
- Tips and tricks for getting the most out of markdown sync
## Subscribe
Use the form below to subscribe to our newsletter. We respect your privacy and you can unsubscribe at any time.
## Privacy
We only use your email address to send you newsletter updates. We never share your email with third parties or use it for any other purpose.
To unsubscribe, click the unsubscribe link at the bottom of any newsletter email.

View File

@@ -10,8 +10,12 @@
import type * as aiChatActions from "../aiChatActions.js";
import type * as aiChats from "../aiChats.js";
import type * as contact from "../contact.js";
import type * as contactActions from "../contactActions.js";
import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as newsletter from "../newsletter.js";
import type * as newsletterActions from "../newsletterActions.js";
import type * as pages from "../pages.js";
import type * as posts from "../posts.js";
import type * as rss from "../rss.js";
@@ -27,8 +31,12 @@ import type {
declare const fullApi: ApiFromModules<{
aiChatActions: typeof aiChatActions;
aiChats: typeof aiChats;
contact: typeof contact;
contactActions: typeof contactActions;
crons: typeof crons;
http: typeof http;
newsletter: typeof newsletter;
newsletterActions: typeof newsletterActions;
pages: typeof pages;
posts: typeof posts;
rss: typeof rss;

84
convex/contact.ts Normal file
View File

@@ -0,0 +1,84 @@
import { mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Environment variable error message for production
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL.";
// Submit contact form message
// Stores the message and schedules email sending via AgentMail
export const submitContact = mutation({
args: {
name: v.string(),
email: v.string(),
message: v.string(),
source: v.string(), // "page:slug" or "post:slug"
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Validate required fields
const name = args.name.trim();
const email = args.email.toLowerCase().trim();
const message = args.message.trim();
if (!name) {
return { success: false, message: "Please enter your name." };
}
if (!email || !email.includes("@") || !email.includes(".")) {
return { success: false, message: "Please enter a valid email address." };
}
if (!message) {
return { success: false, message: "Please enter a message." };
}
// Check environment variables before proceeding
// Note: We can't access process.env in mutations, so we check in the action
// But we can still store the message and let the action handle the error
// For now, we'll store the message and let the action fail silently
// The user will see a success message but email won't send if env vars are missing
// Store the message
const messageId = await ctx.db.insert("contactMessages", {
name,
email,
message,
source: args.source,
createdAt: Date.now(),
});
// Schedule email sending via Node.js action
// The action will check env vars and fail silently if not configured
await ctx.scheduler.runAfter(0, internal.contactActions.sendContactEmail, {
messageId,
name,
email,
message,
source: args.source,
});
return {
success: true,
message: "Thanks for your message! We'll get back to you soon.",
};
},
});
// Mark contact message as email sent
// Internal mutation to update emailSentAt timestamp
export const markEmailSent = internalMutation({
args: {
messageId: v.id("contactMessages"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.messageId, {
emailSentAt: Date.now(),
});
return null;
},
});

90
convex/contactActions.ts Normal file
View File

@@ -0,0 +1,90 @@
"use node";
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import { AgentMailClient } from "agentmail";
// Send contact form email via AgentMail SDK
// Internal action that sends email to configured recipient
// Uses official AgentMail SDK: https://docs.agentmail.to/quickstart
export const sendContactEmail = internalAction({
args: {
messageId: v.id("contactMessages"),
name: v.string(),
email: v.string(),
message: v.string(),
source: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
// Contact form sends to AGENTMAIL_CONTACT_EMAIL or falls back to inbox
const recipientEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
// Silently fail if environment variables not configured
if (!apiKey || !inbox || !recipientEmail) {
return null;
}
// Build email HTML
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Contact Form Submission</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600; width: 100px;">From:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Email:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><a href="mailto:${escapeHtml(args.email)}">${escapeHtml(args.email)}</a></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Source:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.source)}</td>
</tr>
</table>
<h3 style="font-size: 16px; color: #1a1a1a; margin: 24px 0 8px 0;">Message:</h3>
<div style="background: #f9f9f9; padding: 16px; border-radius: 6px; white-space: pre-wrap;">${escapeHtml(args.message)}</div>
</div>
`;
// Plain text version
const text = `New Contact Form Submission\n\nFrom: ${args.name}\nEmail: ${args.email}\nSource: ${args.source}\n\nMessage:\n${args.message}`;
try {
// Initialize AgentMail client with API key
const client = new AgentMailClient({ apiKey });
// Send email using official SDK
// https://docs.agentmail.to/sending-receiving-email
await client.inboxes.messages.send(inbox, {
to: recipientEmail,
subject: `Contact: ${args.name} via ${args.source}`,
text,
html,
});
// Mark email as sent in database
await ctx.runMutation(internal.contact.markEmailSent, {
messageId: args.messageId,
});
} catch {
// Silently fail on error
}
return null;
},
});
// Helper function to escape HTML entities
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -11,5 +11,29 @@ crons.interval(
{}
);
// Weekly digest: Send every Sunday at 9:00 AM UTC
// Posts from the last 7 days are included
// To disable, set weeklyDigest.enabled: false in siteConfig.ts
crons.cron(
"weekly newsletter digest",
"0 9 * * 0", // 9:00 AM UTC on Sundays
internal.newsletterActions.sendWeeklyDigest,
{
siteUrl: process.env.SITE_URL || "https://example.com",
siteName: process.env.SITE_NAME || "Newsletter",
}
);
// Weekly stats summary: Send every Monday at 9:00 AM UTC
// Includes subscriber count, new subscribers, newsletters sent
crons.cron(
"weekly stats summary",
"0 9 * * 1", // 9:00 AM UTC on Mondays
internal.newsletterActions.sendWeeklyStatsSummary,
{
siteName: process.env.SITE_NAME || "Newsletter",
}
);
export default crons;

View File

@@ -41,7 +41,7 @@ http.route({
</url>`,
// All posts
...posts.map(
(post) => ` <url>
(post: { slug: string; date: string }) => ` <url>
<loc>${SITE_URL}/${post.slug}</loc>
<lastmod>${post.date}</lastmod>
<changefreq>monthly</changefreq>
@@ -50,7 +50,7 @@ http.route({
),
// All pages
...pages.map(
(page) => ` <url>
(page: { slug: string }) => ` <url>
<loc>${SITE_URL}/${page.slug}</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -58,7 +58,7 @@ http.route({
),
// All tag pages
...tags.map(
(tagInfo) => ` <url>
(tagInfo: { tag: string }) => ` <url>
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
@@ -92,7 +92,7 @@ http.route({
url: SITE_URL,
description:
"An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.",
posts: posts.map((post) => ({
posts: posts.map((post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => ({
title: post.title,
slug: post.slug,
description: post.description,
@@ -193,7 +193,7 @@ http.route({
// Fetch full content for each post
const fullPosts = await Promise.all(
posts.map(async (post) => {
posts.map(async (post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => {
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
slug: post.slug,
});

561
convex/newsletter.ts Normal file
View File

@@ -0,0 +1,561 @@
import {
mutation,
query,
internalQuery,
internalMutation,
} from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Generate secure unsubscribe token
// Uses random alphanumeric characters for URL-safe tokens
function generateToken(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let token = "";
for (let i = 0; i < 32; i++) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
}
// Subscribe to newsletter (email only)
// Creates new subscriber or re-subscribes existing unsubscribed user
// Sends developer notification when a new subscriber signs up
export const subscribe = mutation({
args: {
email: v.string(),
source: v.string(), // "home", "blog-page", "post", or "post:slug-name"
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Normalize email: lowercase and trim whitespace
const email = args.email.toLowerCase().trim();
// Validate email format
if (!email || !email.includes("@") || !email.includes(".")) {
return { success: false, message: "Please enter a valid email address." };
}
// Check if already subscribed using index
const existing = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
if (existing && existing.subscribed) {
return { success: false, message: "You're already subscribed!" };
}
const token = generateToken();
const isNewSubscriber = !existing;
if (existing) {
// Re-subscribe existing user with new token
await ctx.db.patch(existing._id, {
subscribed: true,
subscribedAt: Date.now(),
source: args.source,
unsubscribeToken: token,
unsubscribedAt: undefined,
});
} else {
// Create new subscriber
await ctx.db.insert("newsletterSubscribers", {
email,
subscribed: true,
subscribedAt: Date.now(),
source: args.source,
unsubscribeToken: token,
});
}
// Send developer notification for new subscribers
// Only for genuinely new subscribers, not re-subscriptions
if (isNewSubscriber) {
await ctx.scheduler.runAfter(0, internal.newsletterActions.notifyNewSubscriber, {
email,
source: args.source,
});
}
return { success: true, message: "Thanks for subscribing!" };
},
});
// Unsubscribe from newsletter
// Requires email and token for security (prevents unauthorized unsubscribes)
export const unsubscribe = mutation({
args: {
email: v.string(),
token: v.string(),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Normalize email
const email = args.email.toLowerCase().trim();
// Find subscriber by email using index
const subscriber = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_email", (q) => q.eq("email", email))
.first();
if (!subscriber) {
return { success: false, message: "Email not found." };
}
// Verify token matches
if (subscriber.unsubscribeToken !== args.token) {
return { success: false, message: "Invalid unsubscribe link." };
}
// Check if already unsubscribed
if (!subscriber.subscribed) {
return { success: true, message: "You're already unsubscribed." };
}
// Mark as unsubscribed
await ctx.db.patch(subscriber._id, {
subscribed: false,
unsubscribedAt: Date.now(),
});
return { success: true, message: "You've been unsubscribed." };
},
});
// Get subscriber count (for stats page)
// Returns count of active subscribers
export const getSubscriberCount = query({
args: {},
returns: v.number(),
handler: async (ctx) => {
const subscribers = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_subscribed", (q) => q.eq("subscribed", true))
.collect();
return subscribers.length;
},
});
// Get active subscribers (internal, for sending newsletters)
// Returns only email and token for each active subscriber
export const getActiveSubscribers = internalQuery({
args: {},
returns: v.array(
v.object({
email: v.string(),
unsubscribeToken: v.string(),
})
),
handler: async (ctx) => {
const subscribers = await ctx.db
.query("newsletterSubscribers")
.withIndex("by_subscribed", (q) => q.eq("subscribed", true))
.collect();
return subscribers.map((s) => ({
email: s.email,
unsubscribeToken: s.unsubscribeToken,
}));
},
});
// Record that a post was sent as newsletter
// Internal mutation called after sending newsletter
export const recordPostSent = internalMutation({
args: {
postSlug: v.string(),
sentCount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("newsletterSentPosts", {
postSlug: args.postSlug,
sentAt: Date.now(),
sentCount: args.sentCount,
type: "post",
});
return null;
},
});
// Record that a custom email was sent as newsletter
// Internal mutation called after sending custom newsletter
export const recordCustomSent = internalMutation({
args: {
subject: v.string(),
sentCount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Generate a unique identifier for the custom email
const customId = `custom-${Date.now()}`;
await ctx.db.insert("newsletterSentPosts", {
postSlug: customId,
sentAt: Date.now(),
sentCount: args.sentCount,
type: "custom",
subject: args.subject,
});
return null;
},
});
// Check if a post has already been sent as newsletter
export const wasPostSent = internalQuery({
args: {
postSlug: v.string(),
},
returns: v.boolean(),
handler: async (ctx, args) => {
const sent = await ctx.db
.query("newsletterSentPosts")
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
.first();
return sent !== null;
},
});
// ============================================================================
// Admin Queries and Mutations
// For use in the /newsletter-admin page
// ============================================================================
// Subscriber type for admin queries (excludes sensitive tokens for public queries)
const subscriberAdminValidator = v.object({
_id: v.id("newsletterSubscribers"),
email: v.string(),
subscribed: v.boolean(),
subscribedAt: v.number(),
unsubscribedAt: v.optional(v.number()),
source: v.string(),
});
// Get all subscribers for admin (paginated)
// Returns subscribers without sensitive unsubscribe tokens
export const getAllSubscribers = query({
args: {
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
filter: v.optional(v.union(v.literal("all"), v.literal("subscribed"), v.literal("unsubscribed"))),
search: v.optional(v.string()),
},
returns: v.object({
subscribers: v.array(subscriberAdminValidator),
nextCursor: v.union(v.string(), v.null()),
totalCount: v.number(),
subscribedCount: v.number(),
}),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const filter = args.filter ?? "all";
const search = args.search?.toLowerCase().trim();
// Get all subscribers for counting
const allSubscribers = await ctx.db
.query("newsletterSubscribers")
.collect();
// Filter by subscription status
let filtered = allSubscribers;
if (filter === "subscribed") {
filtered = allSubscribers.filter((s) => s.subscribed);
} else if (filter === "unsubscribed") {
filtered = allSubscribers.filter((s) => !s.subscribed);
}
// Search by email
if (search) {
filtered = filtered.filter((s) => s.email.includes(search));
}
// Sort by subscribedAt descending (newest first)
filtered.sort((a, b) => b.subscribedAt - a.subscribedAt);
// Pagination using cursor (subscribedAt timestamp)
let startIndex = 0;
if (args.cursor) {
const cursorTime = parseInt(args.cursor, 10);
startIndex = filtered.findIndex((s) => s.subscribedAt < cursorTime);
if (startIndex === -1) startIndex = filtered.length;
}
const pageSubscribers = filtered.slice(startIndex, startIndex + limit);
const hasMore = startIndex + limit < filtered.length;
// Map to admin format (strip unsubscribeToken)
const subscribers = pageSubscribers.map((s) => ({
_id: s._id,
email: s.email,
subscribed: s.subscribed,
subscribedAt: s.subscribedAt,
unsubscribedAt: s.unsubscribedAt,
source: s.source,
}));
const subscribedCount = allSubscribers.filter((s) => s.subscribed).length;
return {
subscribers,
nextCursor: hasMore ? String(pageSubscribers[pageSubscribers.length - 1].subscribedAt) : null,
totalCount: filtered.length,
subscribedCount,
};
},
});
// Delete subscriber (admin only)
// Permanently removes subscriber from database
export const deleteSubscriber = mutation({
args: {
subscriberId: v.id("newsletterSubscribers"),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if subscriber exists using direct get
const subscriber = await ctx.db.get(args.subscriberId);
if (!subscriber) {
return { success: false, message: "Subscriber not found." };
}
// Delete the subscriber
await ctx.db.delete(args.subscriberId);
return { success: true, message: "Subscriber deleted." };
},
});
// Get newsletter stats for admin dashboard
export const getNewsletterStats = query({
args: {},
returns: v.object({
totalSubscribers: v.number(),
activeSubscribers: v.number(),
unsubscribedCount: v.number(),
totalNewslettersSent: v.number(),
totalEmailsSent: v.number(), // Sum of all sentCount
recentNewsletters: v.array(
v.object({
postSlug: v.string(),
sentAt: v.number(),
sentCount: v.number(),
type: v.optional(v.string()),
subject: v.optional(v.string()),
})
),
}),
handler: async (ctx) => {
// Get all subscribers
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
const unsubscribedCount = subscribers.length - activeSubscribers;
// Get sent newsletters
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
// Calculate total emails sent (sum of all sentCount)
const totalEmailsSent = sentPosts.reduce((sum, p) => sum + p.sentCount, 0);
// Sort by sentAt descending and take last 10
const recentNewsletters = sentPosts
.sort((a, b) => b.sentAt - a.sentAt)
.slice(0, 10)
.map((p) => ({
postSlug: p.postSlug,
sentAt: p.sentAt,
sentCount: p.sentCount,
type: p.type,
subject: p.subject,
}));
return {
totalSubscribers: subscribers.length,
activeSubscribers,
unsubscribedCount,
totalNewslettersSent: sentPosts.length,
totalEmailsSent,
recentNewsletters,
};
},
});
// Get list of posts available for newsletter sending
export const getPostsForNewsletter = query({
args: {},
returns: v.array(
v.object({
slug: v.string(),
title: v.string(),
date: v.string(),
wasSent: v.boolean(),
})
),
handler: async (ctx) => {
// Get all published posts
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Get all sent post slugs
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
const sentSlugs = new Set(sentPosts.map((p) => p.postSlug));
// Map posts with sent status, sorted by date descending
return posts
.sort((a, b) => b.date.localeCompare(a.date))
.map((p) => ({
slug: p.slug,
title: p.title,
date: p.date,
wasSent: sentSlugs.has(p.slug),
}));
},
});
// Internal query to get stats for weekly summary email
export const getStatsForSummary = internalQuery({
args: {},
returns: v.object({
activeSubscribers: v.number(),
totalSubscribers: v.number(),
newThisWeek: v.number(),
unsubscribedCount: v.number(),
totalNewslettersSent: v.number(),
}),
handler: async (ctx) => {
// Get all subscribers
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
const unsubscribedCount = subscribers.length - activeSubscribers;
// Calculate new subscribers this week
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const newThisWeek = subscribers.filter(
(s) => s.subscribedAt >= oneWeekAgo && s.subscribed
).length;
// Get sent newsletters count
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
return {
activeSubscribers,
totalSubscribers: subscribers.length,
newThisWeek,
unsubscribedCount,
totalNewslettersSent: sentPosts.length,
};
},
});
// ============================================================================
// Admin Mutations for Newsletter Sending
// These schedule internal actions to send newsletters
// ============================================================================
// Schedule sending a post as newsletter from admin UI
export const scheduleSendPostNewsletter = mutation({
args: {
postSlug: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if post was already sent
const sent = await ctx.db
.query("newsletterSentPosts")
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
.first();
if (sent) {
return {
success: false,
message: "This post has already been sent as a newsletter.",
};
}
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendPostNewsletter, {
postSlug: args.postSlug,
siteUrl: args.siteUrl,
siteName: args.siteName,
});
return {
success: true,
message: "Newsletter is being sent. Check back in a moment for results.",
};
},
});
// Schedule sending a custom newsletter from admin UI
export const scheduleSendCustomNewsletter = mutation({
args: {
subject: v.string(),
content: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Validate inputs
if (!args.subject.trim()) {
return { success: false, message: "Subject is required." };
}
if (!args.content.trim()) {
return { success: false, message: "Content is required." };
}
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendCustomNewsletter, {
subject: args.subject,
content: args.content,
siteUrl: args.siteUrl,
siteName: args.siteName,
});
return {
success: true,
message: "Newsletter is being sent. Check back in a moment for results.",
};
},
});
// Schedule sending weekly stats summary from CLI
export const scheduleSendStatsSummary = mutation({
args: {
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Schedule the action to run immediately
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendWeeklyStatsSummary, {
siteName: args.siteName,
});
return {
success: true,
message: "Stats summary is being sent. Check your inbox in a moment.",
};
},
});

552
convex/newsletterActions.ts Normal file
View File

@@ -0,0 +1,552 @@
"use node";
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import { AgentMailClient } from "agentmail";
// Simple markdown to HTML converter for email content
// Supports: headers, bold, italic, links, lists, paragraphs
function markdownToHtml(markdown: string): string {
let html = markdown
// Escape HTML entities first
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Headers (must be at start of line)
.replace(/^### (.+)$/gm, '<h3 style="font-size: 18px; color: #1a1a1a; margin: 16px 0 8px;">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 style="font-size: 20px; color: #1a1a1a; margin: 20px 0 10px;">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 style="font-size: 24px; color: #1a1a1a; margin: 24px 0 12px;">$1</h1>')
// Bold and italic
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: #1a73e8; text-decoration: none;">$1</a>')
// Unordered lists
.replace(/^- (.+)$/gm, '<li style="margin: 4px 0;">$1</li>')
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul style="padding-left: 20px; margin: 12px 0;">$&</ul>')
// Line breaks (double newline = paragraph)
.replace(/\n\n/g, '</p><p style="margin: 12px 0; line-height: 1.6;">')
// Single line breaks
.replace(/\n/g, '<br />');
// Wrap in paragraph if not starting with a block element
if (!html.startsWith('<h') && !html.startsWith('<ul')) {
html = `<p style="margin: 12px 0; line-height: 1.6;">${html}</p>`;
}
return html;
}
// Convert markdown to plain text for email fallback
function markdownToText(markdown: string): string {
return markdown
// Remove markdown formatting
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/_(.+?)_/g, '$1')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
.replace(/^#{1,3} /gm, '')
.replace(/^- /gm, '* ');
}
// Environment variable error message for production
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX.";
// Send newsletter for a specific post to all active subscribers
// Uses AgentMail SDK to send emails
// https://docs.agentmail.to/sending-receiving-email
export const sendPostNewsletter = internalAction({
args: {
postSlug: v.string(),
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
sentCount: v.number(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Check if post was already sent
const alreadySent: boolean = await ctx.runQuery(
internal.newsletter.wasPostSent,
{ postSlug: args.postSlug }
);
if (alreadySent) {
return {
success: false,
sentCount: 0,
message: "This post has already been sent as a newsletter.",
};
}
// Get subscribers
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
if (subscribers.length === 0) {
return { success: false, sentCount: 0, message: "No subscribers." };
}
// Get post details
const post = await ctx.runQuery(internal.posts.getPostBySlugInternal, {
slug: args.postSlug,
});
if (!post) {
return { success: false, sentCount: 0, message: "Post not found." };
}
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
if (!apiKey || !inbox) {
return {
success: false,
sentCount: 0,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Newsletter";
let sentCount = 0;
const errors: Array<string> = [];
// Initialize AgentMail client once
const client = new AgentMailClient({ apiKey });
// Send to each subscriber
for (const subscriber of subscribers) {
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
const postUrl = `${args.siteUrl}/${post.slug}`;
// Build email HTML
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 16px;">${escapeHtml(post.title)}</h1>
<p style="font-size: 16px; color: #444; line-height: 1.6; margin-bottom: 24px;">${escapeHtml(post.description)}</p>
${post.excerpt ? `<p style="font-size: 14px; color: #666; line-height: 1.5; margin-bottom: 24px;">${escapeHtml(post.excerpt)}</p>` : ""}
<p style="margin-bottom: 32px;">
<a href="${postUrl}" style="display: inline-block; padding: 12px 24px; background: #1a1a1a; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Read more</a>
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="font-size: 12px; color: #888;">
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
</p>
</div>
`;
// Plain text version
const text = `${post.title}\n\n${post.description}\n\nRead more: ${postUrl}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
// Send email using AgentMail SDK
try {
await client.inboxes.messages.send(inbox, {
to: subscriber.email,
subject: `New: ${post.title}`,
html,
text,
});
sentCount++;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
errors.push(`${subscriber.email}: ${errorMessage}`);
}
}
// Record sent if at least one email was sent
if (sentCount > 0) {
await ctx.runMutation(internal.newsletter.recordPostSent, {
postSlug: args.postSlug,
sentCount,
});
}
// Build result message
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
if (errors.length > 0) {
resultMessage += ` ${errors.length} failed.`;
}
return { success: sentCount > 0, sentCount, message: resultMessage };
},
});
// Helper function to escape HTML entities
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Send weekly digest email to all active subscribers
// Includes all posts published in the last 7 days
export const sendWeeklyDigest = internalAction({
args: {
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
sentCount: v.number(),
postCount: v.number(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Get subscribers
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
if (subscribers.length === 0) {
return {
success: false,
sentCount: 0,
postCount: 0,
message: "No subscribers.",
};
}
// Get posts from last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const cutoffDate = sevenDaysAgo.toISOString().split("T")[0];
const recentPosts: Array<{
slug: string;
title: string;
description: string;
date: string;
excerpt?: string;
}> = await ctx.runQuery(internal.posts.getRecentPostsInternal, {
since: cutoffDate,
});
if (recentPosts.length === 0) {
return {
success: true,
sentCount: 0,
postCount: 0,
message: "No new posts in the last 7 days.",
};
}
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
if (!apiKey || !inbox) {
return {
success: false,
sentCount: 0,
postCount: 0,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Newsletter";
let sentCount = 0;
const errors: Array<string> = [];
// Initialize AgentMail client
const client = new AgentMailClient({ apiKey });
// Build email content
const postsHtml = recentPosts
.map(
(post) => `
<div style="margin-bottom: 24px; padding: 16px; background: #f9f9f9; border-radius: 8px;">
<h3 style="font-size: 18px; color: #1a1a1a; margin: 0 0 8px 0;">
<a href="${args.siteUrl}/${post.slug}" style="color: #1a1a1a; text-decoration: none;">${escapeHtml(post.title)}</a>
</h3>
<p style="font-size: 14px; color: #666; margin: 0 0 8px 0;">${escapeHtml(post.description)}</p>
<p style="font-size: 12px; color: #888; margin: 0;">${post.date}</p>
</div>
`
)
.join("");
const postsText = recentPosts
.map(
(post) =>
`${post.title}\n${post.description}\n${args.siteUrl}/${post.slug}\n${post.date}`
)
.join("\n\n");
// Send to each subscriber
for (const subscriber of subscribers) {
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 8px;">Weekly Digest</h1>
<p style="font-size: 14px; color: #666; margin-bottom: 24px;">${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""} from ${escapeHtml(siteName)}</p>
${postsHtml}
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="font-size: 12px; color: #888;">
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
</p>
</div>
`;
const text = `Weekly Digest - ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}\n\n${postsText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
try {
await client.inboxes.messages.send(inbox, {
to: subscriber.email,
subject: `Weekly Digest: ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}`,
html,
text,
});
sentCount++;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
errors.push(`${subscriber.email}: ${errorMessage}`);
}
}
let resultMessage: string = `Sent ${recentPosts.length} post${recentPosts.length > 1 ? "s" : ""} to ${sentCount} of ${subscribers.length} subscribers.`;
if (errors.length > 0) {
resultMessage += ` ${errors.length} failed.`;
}
return {
success: sentCount > 0,
sentCount,
postCount: recentPosts.length,
message: resultMessage,
};
},
});
// Send new subscriber notification to developer
// Called when a new subscriber signs up
export const notifyNewSubscriber = internalAction({
args: {
email: v.string(),
source: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (_ctx, args) => {
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
if (!apiKey || !contactEmail) {
return {
success: false,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Your Site";
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
});
const client = new AgentMailClient({ apiKey });
try {
await client.inboxes.messages.send(inbox!, {
to: contactEmail,
subject: `New subscriber: ${args.email}`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Newsletter Subscriber</h2>
<p style="font-size: 14px; color: #444; line-height: 1.6;">
<strong>Email:</strong> ${escapeHtml(args.email)}<br />
<strong>Source:</strong> ${escapeHtml(args.source)}<br />
<strong>Time:</strong> ${timestamp}
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #888;">
This is an automated notification from ${escapeHtml(siteName)}.
</p>
</div>
`,
text: `New Newsletter Subscriber\n\nEmail: ${args.email}\nSource: ${args.source}\nTime: ${timestamp}`,
});
return { success: true, message: "Notification sent." };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { success: false, message: errorMessage };
}
},
});
// Send weekly stats summary to developer
// Includes subscriber count, new subscribers, newsletters sent
export const sendWeeklyStatsSummary = internalAction({
args: {
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
if (!apiKey || !contactEmail) {
return { success: false, message: ENV_VAR_ERROR_MESSAGE };
}
const siteName = args.siteName || "Your Site";
// Get stats from database
const stats = await ctx.runQuery(internal.newsletter.getStatsForSummary);
const client = new AgentMailClient({ apiKey });
try {
await client.inboxes.messages.send(inbox!, {
to: contactEmail,
subject: `Weekly Stats: ${stats.activeSubscribers} subscribers`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">Weekly Newsletter Stats</h2>
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-bottom: 24px;">
<p style="font-size: 14px; color: #444; line-height: 1.8; margin: 0;">
<strong>Active Subscribers:</strong> ${stats.activeSubscribers}<br />
<strong>Total Subscribers:</strong> ${stats.totalSubscribers}<br />
<strong>New This Week:</strong> ${stats.newThisWeek}<br />
<strong>Unsubscribed:</strong> ${stats.unsubscribedCount}<br />
<strong>Newsletters Sent:</strong> ${stats.totalNewslettersSent}
</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
<p style="font-size: 12px; color: #888;">
This is an automated weekly summary from ${escapeHtml(siteName)}.
</p>
</div>
`,
text: `Weekly Newsletter Stats\n\nActive Subscribers: ${stats.activeSubscribers}\nTotal Subscribers: ${stats.totalSubscribers}\nNew This Week: ${stats.newThisWeek}\nUnsubscribed: ${stats.unsubscribedCount}\nNewsletters Sent: ${stats.totalNewslettersSent}`,
});
return { success: true, message: "Stats summary sent." };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { success: false, message: errorMessage };
}
},
});
// Send custom newsletter email to all active subscribers
// Supports markdown content that gets converted to HTML
export const sendCustomNewsletter = internalAction({
args: {
subject: v.string(),
content: v.string(), // Markdown content
siteUrl: v.string(),
siteName: v.optional(v.string()),
},
returns: v.object({
success: v.boolean(),
sentCount: v.number(),
message: v.string(),
}),
handler: async (ctx, args) => {
// Get subscribers
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
if (subscribers.length === 0) {
return { success: false, sentCount: 0, message: "No subscribers." };
}
// Get API key and inbox from environment
const apiKey = process.env.AGENTMAIL_API_KEY;
const inbox = process.env.AGENTMAIL_INBOX;
if (!apiKey || !inbox) {
return {
success: false,
sentCount: 0,
message: ENV_VAR_ERROR_MESSAGE,
};
}
const siteName = args.siteName || "Newsletter";
let sentCount = 0;
const errors: Array<string> = [];
// Convert markdown to HTML and plain text
const contentHtml = markdownToHtml(args.content);
const contentText = markdownToText(args.content);
// Initialize AgentMail client
const client = new AgentMailClient({ apiKey });
// Send to each subscriber
for (const subscriber of subscribers) {
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
// Build email HTML with styling
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
${contentHtml}
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="font-size: 12px; color: #888;">
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
</p>
</div>
`;
const text = `${contentText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
try {
await client.inboxes.messages.send(inbox, {
to: subscriber.email,
subject: args.subject,
html,
text,
});
sentCount++;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
errors.push(`${subscriber.email}: ${errorMessage}`);
}
}
// Record custom email send if at least one email was sent
if (sentCount > 0) {
await ctx.runMutation(internal.newsletter.recordCustomSent, {
subject: args.subject,
sentCount,
});
}
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
if (errors.length > 0) {
resultMessage += ` ${errors.length} failed.`;
}
return { success: sentCount > 0, sentCount, message: resultMessage };
},
});

View File

@@ -128,7 +128,10 @@ export const getPageBySlug = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
}),
v.null(),
),
@@ -161,7 +164,10 @@ export const getPageBySlug = query({
rightSidebar: page.rightSidebar,
showFooter: page.showFooter,
footer: page.footer,
showSocialFooter: page.showSocialFooter,
aiChat: page.aiChat,
contactForm: page.contactForm,
newsletter: page.newsletter,
};
},
});
@@ -188,7 +194,10 @@ export const syncPagesPublic = mutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
}),
),
},
@@ -232,7 +241,10 @@ export const syncPagesPublic = mutation({
rightSidebar: page.rightSidebar,
showFooter: page.showFooter,
footer: page.footer,
showSocialFooter: page.showSocialFooter,
aiChat: page.aiChat,
contactForm: page.contactForm,
newsletter: page.newsletter,
lastSyncedAt: now,
});
updated++;

View File

@@ -1,4 +1,4 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
// Get all published posts, sorted by date descending
@@ -179,7 +179,10 @@ export const getPostBySlug = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
}),
v.null(),
),
@@ -215,11 +218,87 @@ export const getPostBySlug = query({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
aiChat: post.aiChat,
newsletter: post.newsletter,
contactForm: post.contactForm,
};
},
});
// Internal query to get post by slug (for newsletter sending)
// Returns post details needed for newsletter content
export const getPostBySlugInternal = internalQuery({
args: {
slug: v.string(),
},
returns: v.union(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
excerpt: 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 {
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
excerpt: post.excerpt,
};
},
});
// Internal query to get recent posts (for weekly digest)
// Returns published posts with date >= since parameter
export const getRecentPostsInternal = internalQuery({
args: {
since: v.string(), // Date string in YYYY-MM-DD format
},
returns: v.array(
v.object({
slug: v.string(),
title: v.string(),
description: v.string(),
date: v.string(),
excerpt: v.optional(v.string()),
})
),
handler: async (ctx, args) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.collect();
// Filter posts by date and sort descending
const recentPosts = posts
.filter((post) => post.date >= args.since)
.sort((a, b) => b.date.localeCompare(a.date))
.map((post) => ({
slug: post.slug,
title: post.title,
description: post.description,
date: post.date,
excerpt: post.excerpt,
}));
return recentPosts;
},
});
// Internal mutation for syncing posts from markdown files
export const syncPosts = internalMutation({
args: {
@@ -244,8 +323,11 @@ export const syncPosts = internalMutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
}),
),
},
@@ -291,8 +373,11 @@ export const syncPosts = internalMutation({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
newsletter: post.newsletter,
contactForm: post.contactForm,
lastSyncedAt: now,
});
updated++;
@@ -342,8 +427,11 @@ export const syncPostsPublic = mutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
}),
),
},
@@ -389,8 +477,11 @@ export const syncPostsPublic = mutation({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
showSocialFooter: post.showSocialFooter,
aiChat: post.aiChat,
blogFeatured: post.blogFeatured,
newsletter: post.newsletter,
contactForm: post.contactForm,
lastSyncedAt: now,
});
updated++;

View File

@@ -106,7 +106,7 @@ export const rssFeed = httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
const xml = generateRssXml(
posts.map((post) => ({
posts.map((post: { title: string; description: string; slug: string; date: string }) => ({
title: post.title,
description: post.description,
slug: post.slug,
@@ -128,7 +128,7 @@ export const rssFullFeed = httpAction(async (ctx) => {
// Fetch full content for each post
const fullPosts = await Promise.all(
posts.map(async (post) => {
posts.map(async (post: { title: string; description: string; slug: string; date: string; readTime?: string; tags: string[] }) => {
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
slug: post.slug,
});

View File

@@ -23,8 +23,11 @@ export default defineSchema({
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter: v.optional(v.boolean()), // Show social footer on this post (overrides siteConfig default)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
contactForm: v.optional(v.boolean()), // Enable contact form on this post
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
@@ -60,7 +63,10 @@ export default defineSchema({
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
showFooter: v.optional(v.boolean()), // Show footer on this page (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter: v.optional(v.boolean()), // Show social footer on this page (overrides siteConfig default)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
contactForm: v.optional(v.boolean()), // Enable contact form on this page
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
@@ -139,4 +145,40 @@ export default defineSchema({
})
.index("by_session_and_context", ["sessionId", "contextId"])
.index("by_session", ["sessionId"]),
// Newsletter subscribers table
// Stores email subscriptions with unsubscribe tokens
newsletterSubscribers: defineTable({
email: v.string(), // Subscriber email address (lowercase, trimmed)
subscribed: v.boolean(), // Current subscription status
subscribedAt: v.number(), // Timestamp when subscribed
unsubscribedAt: v.optional(v.number()), // Timestamp when unsubscribed (if applicable)
source: v.string(), // Where they signed up: "home", "blog-page", "post", or "post:slug-name"
unsubscribeToken: v.string(), // Secure token for unsubscribe links
})
.index("by_email", ["email"])
.index("by_subscribed", ["subscribed"]),
// Newsletter sent tracking (posts and custom emails)
// Tracks what has been sent to prevent duplicate newsletters
newsletterSentPosts: defineTable({
postSlug: v.string(), // Slug of the post or custom email identifier
sentAt: v.number(), // Timestamp when the newsletter was sent
sentCount: v.number(), // Number of subscribers it was sent to
type: v.optional(v.string()), // "post" or "custom" (default "post" for backwards compat)
subject: v.optional(v.string()), // Subject line for custom emails
})
.index("by_postSlug", ["postSlug"])
.index("by_sentAt", ["sentAt"]),
// Contact form messages
// Stores messages submitted via contact forms on posts/pages
contactMessages: defineTable({
name: v.string(), // Sender's name
email: v.string(), // Sender's email address
message: v.string(), // Message content
source: v.string(), // Where submitted from: "page:slug" or "post:slug"
createdAt: v.number(), // Timestamp when submitted
emailSentAt: v.optional(v.number()), // Timestamp when email was sent (if applicable)
}).index("by_createdAt", ["createdAt"]),
});

View File

@@ -33,7 +33,7 @@ A brief description of each file in the codebase.
| File | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, homepage configuration, AI chat configuration) |
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, social footer configuration, homepage configuration, AI chat configuration, newsletter configuration, contact form configuration) |
### Pages (`src/pages/`)
@@ -45,6 +45,7 @@ A brief description of each file in the codebase.
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. |
| `NewsletterAdmin.tsx` | Three-column newsletter admin page for managing subscribers and sending newsletters. Left sidebar with navigation and stats, main area with searchable subscriber list, right sidebar with send newsletter panel and recent sends. Access at /newsletter-admin, configurable via siteConfig.newsletterAdmin. |
### Components (`src/components/`)
@@ -67,6 +68,9 @@ A brief description of each file in the codebase.
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown or AI chat on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar/aiChat fields |
| `AIChatView.tsx` | AI chat interface component (Agent) using Anthropic Claude API. Supports per-page chat history, page content context, markdown rendering, and copy functionality. Used in Write page (replaces textarea when enabled) and optionally in RightSidebar. Requires ANTHROPIC_API_KEY environment variable in Convex. System prompt configurable via CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT environment variables. Includes error handling for missing API keys. |
| `NewsletterSignup.tsx` | Newsletter signup form component for email-only subscriptions. Displays configurable title/description, validates email, and submits to Convex. Shows on home, blog page, and posts based on siteConfig.newsletter settings. Supports frontmatter override via newsletter: true/false. |
| `ContactForm.tsx` | Contact form component with name, email, and message fields. Displays when contactForm: true in frontmatter. Submits to Convex which sends email via AgentMail to configured recipient. |
| `SocialFooter.tsx` | Social footer component with social icons on left (GitHub, Twitter/X, LinkedIn, etc.) and copyright on right. Configurable via siteConfig.socialFooter. Shows below main footer on homepage, blog posts, and pages. Supports frontmatter override via showSocialFooter: true/false. |
### Context (`src/context/`)
@@ -98,16 +102,19 @@ A brief description of each file in the codebase.
| File | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats) with indexes for tag and AI queries |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, newsletterSubscribers, newsletterSentPosts, contactMessages) with indexes for tag and AI queries. Posts and pages include showSocialFooter field for frontmatter control. |
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts |
| `pages.ts` | Queries and mutations for static pages |
| `search.ts` | Full text search queries across posts and pages |
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
| `crons.ts` | Cron job for stale session cleanup |
| `crons.ts` | Cron jobs for stale session cleanup, weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC) |
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) |
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
| `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. |
| `aiChatActions.ts` | Anthropic Claude API integration action for AI chat responses. Requires ANTHROPIC_API_KEY environment variable in Convex. Uses claude-sonnet-3-5-20240620 model. System prompt configurable via environment variables (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT). Includes error handling for missing API keys with user-friendly error messages. Supports page content context and chat history (last 20 messages). |
| `newsletter.ts` | Newsletter mutations and queries: subscribe, unsubscribe, getSubscriberCount, getActiveSubscribers, getAllSubscribers (admin), deleteSubscriber (admin), getNewsletterStats, getPostsForNewsletter, wasPostSent, recordPostSent. |
| `newsletterActions.ts` | Newsletter actions (Node.js runtime): sendPostNewsletter, sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary. Uses AgentMail SDK for email delivery. |
| `contact.ts` | Contact form mutations and actions: submitContact, sendContactEmail (AgentMail API), markEmailSent. |
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) |
| `tsconfig.json` | Convex TypeScript configuration |
@@ -151,7 +158,11 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
| `showFooter` | Show footer on this post (optional, overrides siteConfig default) |
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
| `showSocialFooter` | Show social footer on this post (optional, overrides siteConfig default) |
| `aiChat` | Enable AI Agent chat in right sidebar (optional). Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
| `blogFeatured` | Show as featured on blog page (optional, first becomes hero, rest in 2-column row) |
| `newsletter` | Override newsletter signup display (optional, true/false) |
| `contactForm` | Enable contact form on this post (optional) |
## Static Pages (`content/pages/`)
@@ -174,7 +185,10 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
| `showFooter` | Show footer on this page (optional, overrides siteConfig default) |
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
| `showSocialFooter` | Show social footer on this page (optional, overrides siteConfig default) |
| `aiChat` | Enable AI Agent chat in right sidebar (optional). Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
| `newsletter` | Override newsletter signup display (optional, true/false) |
| `contactForm` | Enable contact form on this page (optional) |
## Scripts (`scripts/`)
@@ -184,6 +198,8 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
| `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data |
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json) |
| `send-newsletter.ts` | CLI tool for sending newsletter posts (npm run newsletter:send <slug>) |
| `send-newsletter-stats.ts` | CLI tool for sending weekly stats summary (npm run newsletter:send:stats) |
### Sync Commands

View File

@@ -55,6 +55,32 @@
"type": "default",
"slug": null,
"originalHomeRoute": "/home"
},
"newsletter": {
"enabled": false,
"agentmail": {
"inbox": "newsletter@mail.agentmail.to"
},
"signup": {
"home": {
"enabled": false,
"position": "above-footer",
"title": "Stay Updated",
"description": "Get new posts delivered to your inbox."
},
"blogPage": {
"enabled": false,
"position": "above-footer",
"title": "Subscribe",
"description": "Get notified when new posts are published."
},
"posts": {
"enabled": false,
"position": "below-content",
"title": "Enjoyed this post?",
"description": "Subscribe for more updates."
}
}
}
}

13
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@mendable/firecrawl-js": "^1.21.1",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"agentmail": "^0.1.15",
"convex": "^1.17.4",
"date-fns": "^3.3.1",
"gray-matter": "^4.0.3",
@@ -1785,6 +1786,17 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agentmail": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/agentmail/-/agentmail-0.1.15.tgz",
"integrity": "sha512-BXnTcAFbB30RzLxg+gPs2weHGI1e6pndHIieNSd1sXlPX0w52qiz2ZXQebadbBzKet+/Ix46U0WG8nXPZIymMQ==",
"dependencies": {
"ws": "^8.16.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -6970,7 +6982,6 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},

View File

@@ -18,6 +18,8 @@
"sync:all:prod": "npm run sync:prod && npm run sync:discovery:prod",
"import": "npx tsx scripts/import-url.ts",
"configure": "npx tsx scripts/configure-fork.ts",
"newsletter:send": "npx tsx scripts/send-newsletter.ts",
"newsletter:send:stats": "npx tsx scripts/send-newsletter-stats.ts",
"deploy": "npm run sync && npm run build",
"deploy:prod": "npx convex deploy && npm run sync:prod"
},
@@ -27,6 +29,7 @@
"@mendable/firecrawl-js": "^1.21.1",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"agentmail": "^0.1.15",
"convex": "^1.17.4",
"date-fns": "^3.3.1",
"gray-matter": "^4.0.3",

View File

@@ -0,0 +1,96 @@
# AgentMail Contact Form Fix
## Problem
Contact form submissions were failing with 404 errors when trying to send emails via AgentMail. The error message was:
```
Failed to send contact email (404): {"message":"Not Found"}
```
## Root Cause
The code was attempting to use a REST API endpoint that doesn't exist:
```
POST https://api.agentmail.to/v1/inboxes/{inbox_id}/messages
```
AgentMail doesn't expose a public REST API for sending emails. They require using their official SDK (`agentmail` npm package) instead.
Additionally, Convex functions that use Node.js packages (like the AgentMail SDK) must run in the Node.js runtime, which requires the `"use node"` directive. However, mutations and queries must run in V8. This created a conflict when trying to use the SDK in the same file as mutations.
## Solution
### 1. Install Official SDK
```bash
npm install agentmail
```
### 2. Split Actions into Separate Files
Created separate files for Node.js actions:
- `convex/contactActions.ts` - Contains `sendContactEmail` action with `"use node"`
- `convex/newsletterActions.ts` - Contains `sendPostNewsletter` action with `"use node"`
Main files remain in V8 runtime:
- `convex/contact.ts` - Contains mutations (`submitContact`, `markEmailSent`)
- `convex/newsletter.ts` - Contains mutations and queries
### 3. Use SDK Instead of REST API
Replaced fetch calls with the official SDK:
```typescript
// Before (didn't work)
const response = await fetch(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(emailPayload),
});
// After (works)
const client = new AgentMailClient({ apiKey });
await client.inboxes.messages.send(inbox, {
to: recipientEmail,
subject: "...",
text: "...",
html: "...",
});
```
## Files Modified
- `convex/contact.ts` - Removed `"use node"`, kept mutations only
- `convex/contactActions.ts` - New file with `sendContactEmail` action using SDK
- `convex/newsletter.ts` - Removed `"use node"`, kept mutations/queries only
- `convex/newsletterActions.ts` - New file with `sendPostNewsletter` action using SDK
- `package.json` - Added `agentmail` dependency
## Testing
After the fix:
1. Contact form submissions store messages in Convex
2. Emails send successfully via AgentMail SDK
3. No more 404 errors
4. Proper error handling and logging
## Key Learnings
- AgentMail requires their SDK, not direct REST API calls
- Convex actions using Node.js packages need `"use node"` directive
- Mutations/queries must run in V8, actions can run in Node.js
- Split files by runtime requirement to avoid conflicts
## References
- [AgentMail Quickstart](https://docs.agentmail.to/quickstart)
- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email)
- [Convex Actions Documentation](https://docs.convex.dev/functions/actions)

View File

@@ -0,0 +1,159 @@
# AgentMail Newsletter Integration v1
## Overview
Email-only newsletter system integrated with AgentMail. All features are optional and controlled via `siteConfig.ts` and frontmatter.
## Implemented Features
### Phase 1: Newsletter Signup
| Feature | Status | Description |
|---------|--------|-------------|
| Site Config | Done | `NewsletterConfig` interface in `siteConfig.ts` |
| Schema | Done | `newsletterSubscribers` table with indexes |
| Subscribe Mutation | Done | Email validation, duplicate detection, re-subscribe support |
| Unsubscribe Mutation | Done | Token verification for security |
| Subscriber Queries | Done | `getSubscriberCount`, `getActiveSubscribers` |
| NewsletterSignup Component | Done | Email input form with status feedback |
| CSS Styling | Done | Responsive styles for all themes |
| Home Integration | Done | Configurable position (above-footer, below-intro) |
| Blog Page Integration | Done | Configurable position (above-footer, below-posts) |
| Post Integration | Done | Frontmatter override support |
| Unsubscribe Page | Done | `/unsubscribe` route with auto-processing |
### Phase 2: Newsletter Sending
| Feature | Status | Description |
|---------|--------|-------------|
| Sent Posts Schema | Done | `newsletterSentPosts` table to track sent newsletters |
| Send Action | Done | `sendPostNewsletter` internalAction using AgentMail API |
| CLI Script | Done | `npm run newsletter:send <slug>` |
## Files Created/Modified
### New Files
- `convex/newsletter.ts` - Subscribe, unsubscribe, and sending functions
- `src/components/NewsletterSignup.tsx` - React component
- `src/pages/Unsubscribe.tsx` - Unsubscribe page
- `scripts/send-newsletter.ts` - CLI tool for sending newsletters
- `prds/agentmail-newsletter-v1.md` - This file
### Modified Files
- `src/config/siteConfig.ts` - Added `NewsletterConfig` interface
- `convex/schema.ts` - Added `newsletterSubscribers` and `newsletterSentPosts` tables
- `convex/posts.ts` - Added `getPostBySlugInternal` query
- `src/styles/global.css` - Added newsletter component styles
- `src/pages/Home.tsx` - Integrated `NewsletterSignup`
- `src/pages/Blog.tsx` - Integrated `NewsletterSignup`
- `src/pages/Post.tsx` - Integrated `NewsletterSignup` with frontmatter support
- `src/App.tsx` - Added `/unsubscribe` route
- `package.json` - Added `newsletter:send` script
- `fork-config.json.example` - Added newsletter configuration
- `FORK_CONFIG.md` - Added newsletter documentation
## Configuration
### Environment Variables (Convex Dashboard)
| Variable | Description |
|----------|-------------|
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `newsletter@mail.agentmail.to`) |
### Site Config Example
```typescript
newsletter: {
enabled: true,
agentmail: {
inbox: "newsletter@mail.agentmail.to",
},
signup: {
home: {
enabled: true,
position: "above-footer",
title: "Stay Updated",
description: "Get new posts delivered to your inbox.",
},
blogPage: {
enabled: true,
position: "above-footer",
title: "Subscribe",
description: "Get notified when new posts are published.",
},
posts: {
enabled: true,
position: "below-content",
title: "Enjoyed this post?",
description: "Subscribe for more updates.",
},
},
},
```
### Frontmatter Override
```yaml
---
title: My Post
newsletter: false # Hide newsletter on this post
---
```
## Usage
### Collect Subscribers
1. Enable newsletter in `siteConfig.ts`
2. Set environment variables in Convex dashboard
3. Subscribers can sign up from homepage, blog page, or individual posts
### Send Newsletter
```bash
# Check post exists and show send command
npm run newsletter:send <post-slug>
# Or use Convex CLI directly
npx convex run newsletter:sendPostNewsletter '{"postSlug":"slug","siteUrl":"https://site.com","siteName":"Name"}'
```
### View Subscriber Count
Subscriber count is available via the `newsletter.getSubscriberCount` query.
## Database Schema
### newsletterSubscribers
| Field | Type | Description |
|-------|------|-------------|
| email | string | Subscriber email (lowercase, trimmed) |
| subscribed | boolean | Current subscription status |
| subscribedAt | number | Timestamp when subscribed |
| unsubscribedAt | number? | Timestamp when unsubscribed |
| source | string | Signup location ("home", "blog-page", "post:slug") |
| unsubscribeToken | string | Secure token for unsubscribe links |
Indexes: `by_email`, `by_subscribed`
### newsletterSentPosts
| Field | Type | Description |
|-------|------|-------------|
| postSlug | string | Slug of the sent post |
| sentAt | number | Timestamp when sent |
| sentCount | number | Number of subscribers sent to |
Index: `by_postSlug`
## Future Enhancements (Not Implemented)
- Double opt-in confirmation emails
- Contact form integration
- Weekly digest automation
- Subscriber admin UI in dashboard
- Email templates customization

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>

After

Width:  |  Height:  |  Size: 978 B

View File

@@ -8,6 +8,161 @@ Date: 2025-12-27
All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v1.38.0
Released December 27, 2025
**Newsletter CLI improvements**
- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly
- Sends emails in the background instead of printing instructions
- Provides clear success/error feedback
- Shows helpful messages about checking Newsletter Admin for results
- New `newsletter:send:stats` command
- Sends weekly stats summary to your inbox on demand
- Uses `scheduleSendStatsSummary` mutation
- Email sent to AGENTMAIL_INBOX or AGENTMAIL_CONTACT_EMAIL
- New mutation `scheduleSendStatsSummary` in `convex/newsletter.ts`
- Allows CLI to trigger stats summary sending
- Schedules `sendWeeklyStatsSummary` internal action
**Documentation**
- Blog post: "How to use AgentMail with Markdown Sync"
- Complete setup guide for AgentMail integration
- Environment variables configuration
- Newsletter and contact form features
- CLI commands documentation
- Troubleshooting section
- Updated docs.md with new CLI commands
- Updated files.md with new script reference
- Verified all AgentMail features use environment variables (no hardcoded emails)
Updated files: `scripts/send-newsletter.ts`, `scripts/send-newsletter-stats.ts`, `convex/newsletter.ts`, `package.json`, `content/blog/how-to-use-agentmail.md`, `content/pages/docs.md`, `files.md`, `changelog.md`, `content/pages/changelog-page.md`, `TASK.md`
## v1.37.0
Released December 27, 2025
**Newsletter Admin UI**
- Newsletter Admin UI at `/newsletter-admin`
- Three-column layout similar to Write page
- View all subscribers with search and filter (all/active/unsubscribed)
- Stats showing active, total, and sent newsletter counts
- Delete subscribers directly from admin
- Send newsletter panel with two modes:
- Send Post: Select a blog post to send as newsletter
- Write Email: Compose custom email with markdown support
- Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists)
- Copy icon on success messages to copy CLI commands
- Theme-aware success/error styling (no hardcoded green)
- Recent newsletters list showing sent history
- Configurable via `siteConfig.newsletterAdmin`
**Weekly Digest automation**
- Cron job runs every Sunday at 9:00 AM UTC
- Automatically sends all posts published in the last 7 days
- Uses AgentMail SDK for email delivery
- Configurable via `siteConfig.weeklyDigest`
**Developer Notifications**
- New subscriber alerts sent via email when someone subscribes
- Weekly stats summary sent every Monday at 9:00 AM UTC
- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient
- Configurable via `siteConfig.newsletterNotifications`
**Admin queries and mutations**
- `getAllSubscribers`: Paginated subscriber list with search/filter
- `deleteSubscriber`: Remove subscriber from database
- `getNewsletterStats`: Stats for admin dashboard
- `getPostsForNewsletter`: List of posts with sent status
Updated files: `convex/newsletter.ts`, `convex/newsletterActions.ts`, `convex/posts.ts`, `convex/crons.ts`, `src/config/siteConfig.ts`, `src/App.tsx`, `src/styles/global.css`, `src/pages/NewsletterAdmin.tsx`
## v1.36.0
Released December 27, 2025
**Social footer component**
- Social footer component with customizable social links and copyright
- Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more)
- Shows copyright symbol, site name, and auto-updating year on the right
- Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts`
- Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website
- Uses Phosphor icons for consistent styling
- Appears below the main footer on homepage, blog posts, and pages
- Can work independently of the main footer when set via frontmatter
**Frontmatter control for social footer**
- `showSocialFooter` field for posts and pages to override siteConfig defaults
- Set `showSocialFooter: false` to hide on specific posts/pages
- Works like existing `showFooter` field pattern
**Social footer configuration options**
- `enabled`: Global toggle for social footer
- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility
- `socialLinks`: Array of social link objects with platform and URL
- `copyright.siteName`: Site/company name for copyright display
- `copyright.showYear`: Toggle for auto-updating year
Updated files: `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/pages/Home.tsx`, `src/pages/Post.tsx`, `src/pages/Blog.tsx`, `src/styles/global.css`, `src/components/SocialFooter.tsx`
## v1.35.0
Released December 26, 2025
**Image support at top of posts and pages**
- `showImageAtTop` frontmatter field for posts and pages
- Set `showImageAtTop: true` to display the `image` field at the top of the post/page above the header
- Image displays full-width with rounded corners above the post header
- Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails
- Works for both blog posts and static pages
- Image appears above the post header when enabled
Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/pages/Write.tsx`, `src/styles/global.css`
Documentation updated: `content/pages/docs.md`, `content/blog/how-to-publish.md`, `content/blog/using-images-in-posts.md`, `files.md`
## v1.34.0
Released December 26, 2025
**Blog page featured layout with hero post**
- `blogFeatured` frontmatter field for posts to mark as featured on blog page
- First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link
- Remaining `blogFeatured` posts display in 2-column featured row with excerpts
- Regular (non-featured) posts display in 3-column grid without excerpts
- New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display
- New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date
- `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
- Card images use 16:10 landscape aspect ratio
- Footer support on blog page via `siteConfig.footer.showOnBlogPage`
Updated files: `convex/schema.ts`, `convex/posts.ts`, `scripts/sync-posts.ts`, `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/styles/global.css`
## v1.33.1
Released December 26, 2025
**Article centering in sidebar layouts**
- Article content now centers in the middle column when sidebars are present
- Left sidebar stays flush left, right sidebar stays flush right
- Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column
- Works with both two-column (left sidebar only) and three-column (both sidebars) layouts
- Consistent `max-width: 800px` for article content across all sidebar configurations
Updated files: `src/styles/global.css`
## v1.33.0
Released December 26, 2025

View File

@@ -7,37 +7,10 @@ Date: 2025-12-27
You found the contact page. Nice
<!-- contactform -->
## 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.

View File

@@ -99,7 +99,7 @@ Content here...
```
| Field | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `title` | Yes | Post title |
| `description` | Yes | SEO description |
| `date` | Yes | YYYY-MM-DD format |
@@ -115,6 +115,14 @@ Content here...
| `authorName` | No | Author display name shown next to date |
| `authorImage` | No | Round author avatar image URL |
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
| `showFooter` | No | Show footer on this post (overrides siteConfig default) |
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) |
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) |
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
| `contactForm` | No | Enable contact form on this post |
### Static pages
@@ -132,7 +140,7 @@ Content here...
```
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------- |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
@@ -147,6 +155,12 @@ Content here...
| `authorImage` | No | Round author avatar image URL |
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
| `showFooter` | No | Show footer on this page (overrides siteConfig default) |
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) |
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
| `contactForm` | No | Enable contact form on this page |
**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav.
@@ -799,11 +813,86 @@ The `/stats` page displays real-time analytics:
All stats update automatically via Convex subscriptions.
## Newsletter Admin
The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters.
**Features:**
- View and search all subscribers (search bar in header)
- Filter by status (all, active, unsubscribed)
- Delete subscribers
- Send blog posts as newsletters
- Write and send custom emails with markdown support
- View recent newsletter sends (last 10, includes both posts and custom emails)
- Email statistics dashboard with:
- Total emails sent
- Newsletters sent count
- Active subscribers
- Retention rate
- Detailed summary table
**Configuration:**
Enable in `src/config/siteConfig.ts`:
```typescript
newsletterAdmin: {
enabled: true, // Enable /newsletter-admin route
showInNav: false, // Hide from navigation (access via direct URL)
},
```
**Environment Variables (Convex):**
| Variable | Description |
| ------------------------- | --------------------------------------------------- |
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your AgentMail inbox (e.g., `inbox@agentmail.to`) |
| `AGENTMAIL_CONTACT_EMAIL` | Optional contact form recipient (defaults to inbox) |
**Note:** If environment variables are not configured, users will see the error message: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." when attempting to send newsletters or use contact forms.
**Sending Newsletters:**
The admin UI supports two sending modes:
1. **Send Post**: Select a published blog post to send as a newsletter
2. **Write Email**: Compose a custom email with markdown formatting
Custom emails support markdown syntax:
- `# Heading` for headers
- `**bold**` and `*italic*` for emphasis
- `[link text](url)` for links
- `- item` for bullet lists
**CLI Commands:**
You can send newsletters via command line:
```bash
# Send a blog post to all subscribers
npm run newsletter:send <post-slug>
# Send weekly stats summary to your inbox
npm run newsletter:send:stats
```
Example:
```bash
npm run newsletter:send setup-guide
```
The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results.
## API endpoints
| Endpoint | Description |
| ------------------------------ | --------------------------- |
| `/stats` | Real-time analytics |
| `/newsletter-admin` | Newsletter management UI |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |

View File

@@ -0,0 +1,242 @@
# How to use AgentMail with Markdown Sync
> Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog
---
Type: post
Date: 2025-12-27
Reading time: 5 min read
Tags: agentmail, newsletter, email, setup
---
AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage.
## What is AgentMail
AgentMail is an email service designed for AI agents and developers. It handles email sending and receiving without OAuth or MFA requirements, making it ideal for automated workflows.
For this markdown blog framework, AgentMail powers:
- Newsletter subscriptions and sending
- Contact forms on posts and pages
- Developer notifications for new subscribers
- Weekly digest emails
- Weekly stats summaries
## Setup
### 1. Create an AgentMail account
Sign up at [agentmail.to](https://agentmail.to) and create an inbox. Your inbox address will look like `yourname@agentmail.to`.
### 2. Get your API key
In the AgentMail dashboard, navigate to API settings and copy your API key. You'll need this for Convex environment variables.
### 3. Configure Convex environment variables
In your Convex dashboard, go to Settings > Environment Variables and add:
| Variable | Description | Required |
|----------|-------------|----------|
| `AGENTMAIL_API_KEY` | Your AgentMail API key | Yes |
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `markdown@agentmail.to`) | Yes |
| `AGENTMAIL_CONTACT_EMAIL` | Contact form recipient (defaults to inbox if not set) | No |
**Important:** Never hardcode email addresses in your code. Always use environment variables.
### 4. Enable features in siteConfig
Edit `src/config/siteConfig.ts` to enable newsletter and contact form features:
```typescript
newsletter: {
enabled: true,
showOnHomepage: true,
showOnBlogPage: true,
showOnPosts: true,
title: "Subscribe to the newsletter",
description: "Get updates delivered to your inbox",
},
contactForm: {
enabled: true,
title: "Get in touch",
description: "Send us a message",
},
```
## Newsletter features
### Subscriber management
The Newsletter Admin page at `/newsletter-admin` provides:
- View all subscribers with search and filters
- Delete subscribers
- Send blog posts as newsletters
- Write and send custom emails with markdown support
- View email statistics dashboard
- Track recent sends (last 10)
### Sending newsletters
**Via CLI:**
```bash
# Send a specific post to all subscribers
npm run newsletter:send setup-guide
# Send weekly stats summary to your inbox
npm run newsletter:send:stats
```
**Via Admin UI:**
1. Navigate to `/newsletter-admin`
2. Select "Send Post" or "Write Email" from the sidebar
3. Choose a post or compose a custom email
4. Click "Send Newsletter"
### Weekly digest
Automated weekly digest emails are sent every Sunday at 9:00 AM UTC. They include all posts published in the last 7 days.
Configure in `siteConfig.ts`:
```typescript
weeklyDigest: {
enabled: true,
},
```
### Developer notifications
Receive email notifications when:
- A new subscriber signs up
- Weekly stats summary (every Monday at 9:00 AM UTC)
Configure in `siteConfig.ts`:
```typescript
newsletterNotifications: {
enabled: true,
},
```
Notifications are sent to `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` if contact email is not set.
## Contact forms
### Enable on posts and pages
Add `contactForm: true` to any post or page frontmatter:
```markdown
---
title: "Contact Us"
slug: "contact"
published: true
contactForm: true
---
Your page content here...
```
The contact form includes:
- Name field
- Email field
- Message field
Submissions are stored in Convex and sent via AgentMail to your configured recipient.
### Frontmatter options
| Field | Type | Description |
|-------|------|-------------|
| `contactForm` | boolean | Enable contact form on this post/page |
## Frontmatter options
### Newsletter signup
Control newsletter signup display per post/page:
```markdown
---
title: "My Post"
newsletter: true # Show signup (default: follows siteConfig)
---
```
Or hide it:
```markdown
---
title: "My Post"
newsletter: false # Hide signup even if enabled globally
---
```
## Environment variables
All AgentMail features require these Convex environment variables:
**Required:**
- `AGENTMAIL_API_KEY` - Your AgentMail API key
- `AGENTMAIL_INBOX` - Your inbox address
**Optional:**
- `AGENTMAIL_CONTACT_EMAIL` - Contact form recipient (defaults to inbox)
**Note:** If environment variables are not configured, users will see: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
## CLI commands
| Command | Description |
|---------|-------------|
| `npm run newsletter:send <slug>` | Send a blog post to all subscribers |
| `npm run newsletter:send:stats` | Send weekly stats summary to your inbox |
## Troubleshooting
**Emails not sending:**
1. Verify `AGENTMAIL_API_KEY` and `AGENTMAIL_INBOX` are set in Convex dashboard
2. Check Convex function logs for error messages
3. Ensure your inbox is active in AgentMail dashboard
**Contact form not appearing:**
1. Verify `contactForm: true` is in frontmatter
2. Check `siteConfig.contactForm.enabled` is `true`
3. Run `npm run sync` to sync frontmatter changes
**Newsletter Admin not accessible:**
1. Verify `siteConfig.newsletterAdmin.enabled` is `true`
2. Navigate to `/newsletter-admin` directly (hidden from nav by default)
## Resources
- [AgentMail Documentation](https://docs.agentmail.to)
- [AgentMail Quickstart](https://docs.agentmail.to/quickstart)
- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email)
- [AgentMail Inboxes](https://docs.agentmail.to/inboxes)
## Summary
AgentMail integration provides:
- Newsletter subscriptions and sending
- Contact forms on any post or page
- Automated weekly digests
- Developer notifications
- Admin UI for subscriber management
- CLI tools for sending newsletters and stats
All features use Convex environment variables for configuration. No hardcoded emails in your codebase.

View File

@@ -0,0 +1,66 @@
# How to use Firecrawl
> Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat.
---
Type: post
Date: 2025-01-20
Reading time: 2 min read
Tags: tutorial, firecrawl, import
---
# How to use Firecrawl
You found an article you want to republish or reference. Copying content manually takes time. Firecrawl scrapes web pages and converts them to markdown automatically.
## What it is
Firecrawl is a web scraping service that turns any URL into clean markdown. This app uses it in two places: the import script for creating draft posts, and the AI chat feature for fetching page content.
## Who it's for
Developers who want to import external articles without manual copying. If you republish content or need to reference external sources, Firecrawl saves time.
## The problem it solves
Manually copying content from websites is slow. You copy text, fix formatting, add frontmatter, and handle images. Firecrawl does this automatically.
## How it works
The import script scrapes a URL, extracts the title and description, converts HTML to markdown, and creates a draft post in `content/blog/`. The AI chat feature uses Firecrawl to fetch page content when you share URLs in conversations.
## How to try it
**Step 1: Get your API key**
Visit [firecrawl.dev](https://firecrawl.dev) and sign up. Copy your API key. It starts with `fc-`.
**Step 2: Set up local imports**
Add the key to `.env.local` in your project root:
```
FIRECRAWL_API_KEY=fc-your-api-key-here
```
Now you can import articles:
```bash
npm run import https://example.com/article
```
This creates a draft post in `content/blog/`. Review it, set `published: true`, then run `npm run sync`.
**Step 3: Enable AI chat scraping**
If you use the AI chat feature, set the same key in your Convex Dashboard:
1. Go to [dashboard.convex.dev](https://dashboard.convex.dev)
2. Select your project
3. Open Settings > Environment Variables
4. Add `FIRECRAWL_API_KEY` with your key value
5. Deploy: `npx convex deploy`
The AI chat can now fetch content from URLs you share.
That's it. One API key, two places to set it, and you're done.

View File

@@ -2,8 +2,10 @@
This is the homepage index of all published content.
## Blog Posts (12)
## Blog Posts (14)
- **[How to use AgentMail with Markdown Sync](/raw/how-to-use-agentmail.md)** - Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog
- Date: 2025-12-27 | Reading time: 5 min read | Tags: agentmail, newsletter, email, setup
- **[Happy holidays and thank you](/raw/happy-holidays-2025.md)** - A quick note of thanks for stars, forks, and feedback. More AI-first publishing features coming in 2026.
- Date: 2025-12-25 | Reading time: 2 min read | Tags: updates, community, ai
- **[Netlify edge functions blocking AI crawlers from static files](/raw/netlify-edge-excludedpath-ai-crawlers.md)** - Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works.
@@ -26,19 +28,22 @@ This is the homepage index of all published content.
- Date: 2025-12-14 | Reading time: 8 min read | Tags: convex, netlify, tutorial, deployment
- **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts.
- Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph
- **[How to use Firecrawl](/raw/how-to-use-firecrawl.md)** - Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat.
- Date: 2025-01-20 | Reading time: 2 min read | Tags: tutorial, firecrawl, import
- **[Git commit message best practices](/raw/git-commit-message-best-practices.md)** - A guide to writing clear, consistent commit messages that help your team understand changes and generate better changelogs.
- Date: 2025-01-17 | Reading time: 5 min read | Tags: git, development, best-practices, workflow
## Pages (5)
## Pages (6)
- **[Docs](/raw/docs.md)**
- **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs..
- **[Projects](/raw/projects.md)**
- **[Contact](/raw/contact.md)**
- **[Changelog](/raw/changelog.md)**
- **[Newsletter](/raw/newsletter.md)**
---
**Total Content:** 12 posts, 5 pages
**Total Content:** 14 posts, 6 pages
All content is available as raw markdown files at `/raw/{slug}.md`

28
public/raw/newsletter.md Normal file
View File

@@ -0,0 +1,28 @@
# Newsletter
---
Type: page
Date: 2025-12-27
---
# Newsletter
Stay updated with the latest posts and updates from the markdown sync framework.
## What you will get
When you subscribe, you will receive:
- Notifications when new blog posts are published
- Updates about new features and improvements
- Tips and tricks for getting the most out of markdown sync
## Subscribe
Use the form below to subscribe to our newsletter. We respect your privacy and you can unsubscribe at any time.
## Privacy
We only use your email address to send you newsletter updates. We never share your email with third parties or use it for any other purpose.
To unsubscribe, click the unsubscribe link at the bottom of any newsletter email.

View File

@@ -56,6 +56,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Visitor Map](#visitor-map)
- [Logo Gallery](#logo-gallery)
- [Blog page](#blog-page)
- [Hardcoded Navigation Items](#hardcoded-navigation-items)
- [Scroll-to-top button](#scroll-to-top-button)
- [Change the Default Theme](#change-the-default-theme)
- [Change the Font](#change-the-font)
@@ -77,6 +78,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Build failures on Netlify](#build-failures-on-netlify)
- [Project Structure](#project-structure)
- [Write Page](#write-page)
- [AI Agent chat](#ai-agent-chat)
- [Next Steps](#next-steps)
## Prerequisites
@@ -181,6 +183,7 @@ export default defineSchema({
Blog posts live in `content/blog/` as markdown files. Sync them to Convex:
**Development:**
```bash
npm run sync # Sync markdown content
npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt)
@@ -188,6 +191,7 @@ npm run sync:all # Sync content + discovery files together
```
**Production:**
```bash
npm run sync:prod # Sync markdown content
npm run sync:discovery:prod # Update discovery files
@@ -325,7 +329,7 @@ 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) |
@@ -403,6 +407,7 @@ The `npm run sync` command only syncs markdown text content. Images are deployed
After adding or editing posts, sync to Convex.
**Development sync:**
```bash
npm run sync # Sync markdown content
npm run sync:discovery # Update discovery files
@@ -420,6 +425,7 @@ 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 # Sync markdown content
npm run sync:discovery:prod # Update discovery files
@@ -438,7 +444,7 @@ Both files are gitignored. Each developer creates their own local environment fi
### When to Sync vs Deploy
| What you're changing | Command | Timing |
| -------------------------------- | -------------------------- | -------------------- |
| -------------------------------- | -------------------------- | ----------------------- |
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
@@ -965,15 +971,12 @@ body {
serif;
/* Monospace */
font-family:
"IBM Plex Mono",
"Liberation Mono",
ui-monospace,
monospace;
font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace;
}
```
Available font options:
- `serif`: New York serif font (default)
- `sans`: System sans-serif fonts
- `monospace`: IBM Plex Mono monospace font
@@ -1097,6 +1100,49 @@ How it works:
- A cron job cleans up stale sessions every 5 minutes
- No personal data is stored (only anonymous UUIDs)
## Newsletter Admin
A newsletter management interface is available at `/newsletter-admin`. Use it to view subscribers, send newsletters, and compose custom emails.
**Features:**
- View and search all subscribers with filtering options (search bar in header)
- Delete subscribers from the admin UI
- Send published blog posts as newsletters
- Write custom emails using markdown formatting
- View recent newsletter sends (last 10, tracks both posts and custom emails)
- Email statistics dashboard with comprehensive metrics
**Setup:**
1. Enable in `src/config/siteConfig.ts`:
```typescript
newsletterAdmin: {
enabled: true,
showInNav: false, // Keep hidden, access via direct URL
},
```
2. Set environment variables in Convex Dashboard:
| Variable | Description |
| ------------------------- | ------------------------------------ |
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
| `AGENTMAIL_INBOX` | Your AgentMail inbox address |
| `AGENTMAIL_CONTACT_EMAIL` | Optional recipient for contact forms |
**Important:** If environment variables are not configured, users will see an error message when attempting to use newsletter or contact form features: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
**Sending newsletters:**
Two modes are available:
1. **Send Post**: Select a blog post to send to all active subscribers
2. **Write Email**: Compose custom content with markdown support
The admin UI shows send results and provides CLI commands as alternatives.
## Mobile Navigation
On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route.
@@ -1106,7 +1152,7 @@ On mobile and tablet screens (under 768px), a hamburger menu provides navigation
Each post and page includes a share dropdown with options for AI tools:
| Option | Description |
| -------------------- | ------------------------------------------------- |
| -------------------- | ------------------------------------------ |
| Copy page | Copies formatted markdown to clipboard |
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
| Open in Claude | Opens Claude with raw markdown URL |
@@ -1117,7 +1163,7 @@ Each post and page includes a share dropdown with options for AI tools:
**Git push required for AI links:** The "Open in ChatGPT," "Open in Claude," and "Open in Perplexity" options use GitHub raw URLs to fetch content. For these to work, your content must be pushed to GitHub with `git push`. The `npm run sync` command syncs content to Convex for your live site, but AI services fetch directly from GitHub.
| What you want | Command needed |
| ------------------------------------ | ------------------------------ |
| ------------------------------------ | ------------------------------------------------- |
| Content visible on your site | `npm run sync` or `sync:prod` |
| Discovery files updated | `npm run sync:discovery` or `sync:discovery:prod` |
| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub |

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env npx ts-node
/**
* Send weekly stats summary email to developer
*
* Usage:
* npm run newsletter:send:stats
*
* Environment variables (from .env.local):
* - VITE_CONVEX_URL: Convex deployment URL
* - SITE_NAME: Your site name (default: "Newsletter")
*
* Note: AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL must be set in Convex dashboard
*/
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import dotenv from "dotenv";
// Load environment variables
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env.production.local" });
async function main() {
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) {
console.error(
"Error: VITE_CONVEX_URL not found. Run 'npx convex dev' to create .env.local"
);
process.exit(1);
}
const siteName = process.env.SITE_NAME || "Newsletter";
console.log("Sending weekly stats summary...");
console.log(`Site name: ${siteName}`);
console.log("");
const client = new ConvexHttpClient(convexUrl);
try {
// Call the mutation to schedule the stats summary send
const result = await client.mutation(api.newsletter.scheduleSendStatsSummary, {
siteName,
});
if (result.success) {
console.log("✓ Stats summary scheduled successfully!");
console.log(result.message);
console.log("");
console.log("The email will be sent to AGENTMAIL_INBOX (or AGENTMAIL_CONTACT_EMAIL if set).");
} else {
console.error("✗ Failed to send stats summary:");
console.error(result.message);
process.exit(1);
}
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
main();

101
scripts/send-newsletter.ts Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env npx ts-node
/**
* Send newsletter for a specific post to all subscribers
*
* Usage:
* npm run newsletter:send <post-slug>
*
* Example:
* npm run newsletter:send setup-guide
*
* Environment variables (from .env.local):
* - VITE_CONVEX_URL: Convex deployment URL
* - SITE_URL: Your site URL (default: https://markdown.fast)
* - SITE_NAME: Your site name (default: "Newsletter")
*
* Note: AGENTMAIL_API_KEY and AGENTMAIL_INBOX must be set in Convex dashboard
*/
import { ConvexHttpClient } from "convex/browser";
import { api } from "../convex/_generated/api";
import dotenv from "dotenv";
// Load environment variables
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env.production.local" });
async function main() {
const postSlug = process.argv[2];
if (!postSlug) {
console.error("Usage: npm run newsletter:send <post-slug>");
console.error("Example: npm run newsletter:send setup-guide");
process.exit(1);
}
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) {
console.error(
"Error: VITE_CONVEX_URL not found. Run 'npx convex dev' to create .env.local"
);
process.exit(1);
}
const siteUrl = process.env.SITE_URL || "https://markdown.fast";
const siteName = process.env.SITE_NAME || "Newsletter";
console.log(`Sending newsletter for post: ${postSlug}`);
console.log(`Site URL: ${siteUrl}`);
console.log(`Site name: ${siteName}`);
console.log("");
const client = new ConvexHttpClient(convexUrl);
try {
// First check if post exists
const post = await client.query(api.posts.getPostBySlug, { slug: postSlug });
if (!post) {
console.error(`Error: Post "${postSlug}" not found or not published.`);
process.exit(1);
}
console.log(`Found post: "${post.title}"`);
// Get subscriber count first
const subscriberCount = await client.query(api.newsletter.getSubscriberCount);
console.log(`Active subscribers: ${subscriberCount}`);
if (subscriberCount === 0) {
console.log("No subscribers to send to.");
process.exit(0);
}
console.log("");
console.log("Sending newsletter...");
// Call the mutation directly to schedule the newsletter send
const result = await client.mutation(api.newsletter.scheduleSendPostNewsletter, {
postSlug,
siteUrl,
siteName,
});
if (result.success) {
console.log("✓ Newsletter scheduled successfully!");
console.log(result.message);
console.log("");
console.log("The newsletter is being sent in the background.");
console.log("Check the Newsletter Admin page or recent sends to see results.");
} else {
console.error("✗ Failed to send newsletter:");
console.error(result.message);
process.exit(1);
}
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
main();

View File

@@ -39,8 +39,13 @@ interface PostFrontmatter {
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter?: boolean; // Show social footer on this post (overrides siteConfig default)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
blogFeatured?: boolean; // Show as hero featured post on /blog page
newsletter?: boolean; // Override newsletter signup display (true/false)
contactForm?: boolean; // Enable contact form on this post
}
interface ParsedPost {
@@ -63,8 +68,11 @@ interface ParsedPost {
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter?: boolean; // Show social footer on this post (overrides siteConfig default)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
blogFeatured?: boolean; // Show as hero featured post on /blog page
newsletter?: boolean; // Override newsletter signup display (true/false)
contactForm?: boolean; // Enable contact form on this post
}
// Page frontmatter (for static pages like About, Projects, Contact)
@@ -84,7 +92,11 @@ interface PageFrontmatter {
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter?: boolean; // Show social footer on this page (overrides siteConfig default)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
contactForm?: boolean; // Enable contact form on this page
newsletter?: boolean; // Override newsletter signup display (true/false)
}
interface ParsedPage {
@@ -104,7 +116,11 @@ interface ParsedPage {
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
showSocialFooter?: boolean; // Show social footer on this page (overrides siteConfig default)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
contactForm?: boolean; // Enable contact form on this page
newsletter?: boolean; // Override newsletter signup display (true/false)
}
// Calculate reading time based on word count
@@ -149,8 +165,11 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
showFooter: frontmatter.showFooter, // Show footer on this post
footer: frontmatter.footer, // Footer markdown content
showSocialFooter: frontmatter.showSocialFooter, // Show social footer on this post
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
blogFeatured: frontmatter.blogFeatured, // Show as hero featured post on /blog page
newsletter: frontmatter.newsletter, // Override newsletter signup display
contactForm: frontmatter.contactForm, // Enable contact form on this post
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);
@@ -205,7 +224,11 @@ function parsePageFile(filePath: string): ParsedPage | null {
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
showFooter: frontmatter.showFooter, // Show footer on this page
footer: frontmatter.footer, // Footer markdown content
showSocialFooter: frontmatter.showSocialFooter, // Show social footer on this page
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
contactForm: frontmatter.contactForm, // Enable contact form on this page
newsletter: frontmatter.newsletter, // Override newsletter signup display
};
} catch (error) {
console.error(`Error parsing page ${filePath}:`, error);

View File

@@ -5,6 +5,8 @@ import Stats from "./pages/Stats";
import Blog from "./pages/Blog";
import Write from "./pages/Write";
import TagPage from "./pages/TagPage";
import Unsubscribe from "./pages/Unsubscribe";
import NewsletterAdmin from "./pages/NewsletterAdmin";
import Layout from "./components/Layout";
import { usePageTracking } from "./hooks/usePageTracking";
import { SidebarProvider } from "./context/SidebarContext";
@@ -20,6 +22,11 @@ function App() {
return <Write />;
}
// Newsletter admin page renders without Layout (full-screen admin)
if (location.pathname === "/newsletter-admin") {
return <NewsletterAdmin />;
}
// Determine if we should use a custom homepage
const useCustomHomepage =
siteConfig.homepage.type !== "default" && siteConfig.homepage.slug;
@@ -55,6 +62,8 @@ function App() {
/>
)}
<Route path="/stats" element={<Stats />} />
{/* Unsubscribe route for newsletter */}
<Route path="/unsubscribe" element={<Unsubscribe />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (
<Route path="/blog" element={<Blog />} />

View File

@@ -7,6 +7,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Copy, Check } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
import NewsletterSignup from "./NewsletterSignup";
import ContactForm from "./ContactForm";
import siteConfig from "../config/siteConfig";
// Sanitize schema that allows collapsible sections (details/summary)
const sanitizeSchema = {
@@ -261,6 +264,61 @@ const cursorTanTheme: { [key: string]: React.CSSProperties } = {
interface BlogPostProps {
content: string;
slug?: string; // For tracking source of newsletter/contact form signups
pageType?: "post" | "page"; // Type of content (for tracking)
}
// Content segment types for inline embeds
type ContentSegment =
| { type: "content"; value: string }
| { type: "newsletter" }
| { type: "contactform" };
// Parse content for inline embed placeholders
// Supports: <!-- newsletter --> and <!-- contactform -->
function parseContentForEmbeds(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
// Pattern matches <!-- newsletter --> or <!-- contactform --> (case insensitive)
const pattern = /<!--\s*(newsletter|contactform)\s*-->/gi;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
// Add content before the placeholder
if (match.index > lastIndex) {
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
segments.push({ type: "content", value: textBefore });
}
}
// Add the embed placeholder
const embedType = match[1].toLowerCase();
if (embedType === "newsletter") {
segments.push({ type: "newsletter" });
} else if (embedType === "contactform") {
segments.push({ type: "contactform" });
}
lastIndex = match.index + match[0].length;
}
// Add remaining content after last placeholder
if (lastIndex < content.length) {
const remaining = content.slice(lastIndex);
if (remaining.trim()) {
segments.push({ type: "content", value: remaining });
}
}
// If no placeholders found, return single content segment
if (segments.length === 0) {
segments.push({ type: "content", value: content });
}
return segments;
}
// Generate slug from heading text for anchor links
@@ -308,7 +366,7 @@ function HeadingAnchor({ id }: { id: string }) {
);
}
export default function BlogPost({ content }: BlogPostProps) {
export default function BlogPost({ content, slug, pageType = "post" }: BlogPostProps) {
const { theme } = useTheme();
const getCodeTheme = () => {
@@ -324,6 +382,233 @@ export default function BlogPost({ content }: BlogPostProps) {
}
};
// Parse content for inline embeds
const segments = parseContentForEmbeds(content);
const hasInlineEmbeds = segments.some((s) => s.type !== "content");
// Helper to render a single markdown segment
const renderMarkdown = (markdownContent: string, key?: number) => (
<ReactMarkdown
key={key}
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={{
code(codeProps) {
const { className, children, node, style, ...restProps } = codeProps as {
className?: string;
children?: React.ReactNode;
node?: { tagName?: string; properties?: { className?: string[] } };
style?: React.CSSProperties;
inline?: boolean;
};
const match = /language-(\w+)/.exec(className || "");
// Detect inline code: no language class AND content is short without newlines
const codeContent = String(children);
const hasNewlines = codeContent.includes('\n');
const isShort = codeContent.length < 80;
const hasLanguage = !!match || !!className;
// It's inline only if: no language, short content, no newlines
const isInline = !hasLanguage && isShort && !hasNewlines;
if (isInline) {
return (
<code className="inline-code" style={style} {...restProps}>
{children}
</code>
);
}
const codeString = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text";
const isTextBlock = language === "text";
// Custom styles for text blocks to enable wrapping
const textBlockStyle = isTextBlock ? {
whiteSpace: "pre-wrap" as const,
wordWrap: "break-word" as const,
overflowWrap: "break-word" as const,
} : {};
return (
<div className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}>
{match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} />
<SyntaxHighlighter
style={getCodeTheme()}
language={language}
PreTag="div"
customStyle={textBlockStyle}
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined}
>
{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">
<HeadingAnchor id={id} />
{children}
</h1>
);
},
h2({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h2 id={id} className="blog-h2">
<HeadingAnchor id={id} />
{children}
</h2>
);
},
h3({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h3 id={id} className="blog-h3">
<HeadingAnchor id={id} />
{children}
</h3>
);
},
h4({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h4 id={id} className="blog-h4">
<HeadingAnchor id={id} />
{children}
</h4>
);
},
h5({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h5 id={id} className="blog-h5">
<HeadingAnchor id={id} />
{children}
</h5>
);
},
h6({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
<HeadingAnchor id={id} />
{children}
</h6>
);
},
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" />;
},
// Table components for GitHub-style tables
table({ children }) {
return (
<div className="blog-table-wrapper">
<table className="blog-table">{children}</table>
</div>
);
},
thead({ children }) {
return <thead className="blog-thead">{children}</thead>;
},
tbody({ children }) {
return <tbody className="blog-tbody">{children}</tbody>;
},
tr({ children }) {
return <tr className="blog-tr">{children}</tr>;
},
th({ children }) {
return <th className="blog-th">{children}</th>;
},
td({ children }) {
return <td className="blog-td">{children}</td>;
},
}}
>
{markdownContent}
</ReactMarkdown>
);
// Build source string for tracking
const sourcePrefix = pageType === "page" ? "page" : "post";
const source = slug ? `${sourcePrefix}:${slug}` : sourcePrefix;
// Render with inline embeds if placeholders exist
if (hasInlineEmbeds) {
return (
<article className="blog-post-content">
{segments.map((segment, index) => {
if (segment.type === "newsletter") {
// Newsletter signup inline
return siteConfig.newsletter?.enabled ? (
<NewsletterSignup
key={`newsletter-${index}`}
source={pageType === "page" ? "post" : "post"}
postSlug={slug}
/>
) : null;
}
if (segment.type === "contactform") {
// Contact form inline
return siteConfig.contactForm?.enabled ? (
<ContactForm
key={`contactform-${index}`}
source={source}
/>
) : null;
}
// Markdown content segment
return renderMarkdown(segment.value, index);
})}
</article>
);
}
// No inline embeds, render content normally
return (
<article className="blog-post-content">
<ReactMarkdown

View File

@@ -0,0 +1,167 @@
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import siteConfig from "../config/siteConfig";
// Props for the ContactForm component
interface ContactFormProps {
source: string; // "page:slug" or "post:slug"
title?: string; // Optional title override
description?: string; // Optional description override
}
// Contact form component
// Displays a form with name, email, and message fields
// Submits to Convex which sends email via AgentMail
export default function ContactForm({
source,
title,
description,
}: ContactFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [statusMessage, setStatusMessage] = useState("");
const submitContact = useMutation(api.contact.submitContact);
// Check if contact form is enabled globally
if (!siteConfig.contactForm?.enabled) return null;
// Use provided title/description or fall back to config defaults
const displayTitle = title || siteConfig.contactForm.title;
const displayDescription = description || siteConfig.contactForm.description;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Basic validation
if (!name.trim()) {
setStatus("error");
setStatusMessage("Please enter your name.");
return;
}
if (!email.trim()) {
setStatus("error");
setStatusMessage("Please enter your email.");
return;
}
if (!message.trim()) {
setStatus("error");
setStatusMessage("Please enter a message.");
return;
}
setStatus("loading");
try {
const result = await submitContact({
name: name.trim(),
email: email.trim(),
message: message.trim(),
source,
});
if (result.success) {
setStatus("success");
setStatusMessage(result.message);
// Clear form on success
setName("");
setEmail("");
setMessage("");
} else {
setStatus("error");
setStatusMessage(result.message);
}
} catch {
setStatus("error");
setStatusMessage("Something went wrong. Please try again.");
}
};
return (
<section className="contact-form">
<div className="contact-form__content">
<h3 className="contact-form__title">{displayTitle}</h3>
{displayDescription && (
<p className="contact-form__description">{displayDescription}</p>
)}
{status === "success" ? (
<div className="contact-form__success">
<p>{statusMessage}</p>
<button
type="button"
className="contact-form__reset-button"
onClick={() => setStatus("idle")}
>
Send another message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="contact-form__form">
<div className="contact-form__field">
<label htmlFor="contact-name" className="contact-form__label">
Name
</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="contact-form__input"
disabled={status === "loading"}
/>
</div>
<div className="contact-form__field">
<label htmlFor="contact-email" className="contact-form__label">
Email
</label>
<input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="contact-form__input"
disabled={status === "loading"}
/>
</div>
<div className="contact-form__field">
<label htmlFor="contact-message" className="contact-form__label">
Message
</label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Your message..."
className="contact-form__textarea"
rows={5}
disabled={status === "loading"}
/>
</div>
<button
type="submit"
className="contact-form__button"
disabled={status === "loading"}
>
{status === "loading" ? "Sending..." : "Send Message"}
</button>
</form>
)}
{status === "error" && (
<p className="contact-form__error">{statusMessage}</p>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,116 @@
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import siteConfig from "../config/siteConfig";
// Props for the newsletter signup component
interface NewsletterSignupProps {
source: "home" | "blog-page" | "post"; // Where the signup form appears
postSlug?: string; // For tracking which post they subscribed from
title?: string; // Override default title
description?: string; // Override default description
}
// Newsletter signup component
// Displays email input form for newsletter subscriptions
// Integrates with Convex backend for subscriber management
export default function NewsletterSignup({
source,
postSlug,
title,
description,
}: NewsletterSignupProps) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [message, setMessage] = useState("");
const subscribe = useMutation(api.newsletter.subscribe);
// Check if newsletter is enabled globally
if (!siteConfig.newsletter?.enabled) return null;
// Get config for this placement
const config =
source === "home"
? siteConfig.newsletter.signup.home
: source === "blog-page"
? siteConfig.newsletter.signup.blogPage
: siteConfig.newsletter.signup.posts;
// Check if this specific placement is enabled
if (!config.enabled) return null;
const displayTitle = title || config.title;
const displayDescription = description || config.description;
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setStatus("error");
setMessage("Please enter your email.");
return;
}
setStatus("loading");
try {
// Include post slug in source for tracking
const sourceValue = postSlug ? `post:${postSlug}` : source;
const result = await subscribe({ email, source: sourceValue });
if (result.success) {
setStatus("success");
setMessage(result.message);
setEmail("");
} else {
setStatus("error");
setMessage(result.message);
}
} catch {
setStatus("error");
setMessage("Something went wrong. Please try again.");
}
};
return (
<section className="newsletter-signup">
<div className="newsletter-signup__content">
<h3 className="newsletter-signup__title">{displayTitle}</h3>
{displayDescription && (
<p className="newsletter-signup__description">{displayDescription}</p>
)}
{status === "success" ? (
<p className="newsletter-signup__success">{message}</p>
) : (
<form onSubmit={handleSubmit} className="newsletter-signup__form">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="newsletter-signup__input"
disabled={status === "loading"}
aria-label="Email address"
/>
<button
type="submit"
className="newsletter-signup__button"
disabled={status === "loading"}
>
{status === "loading" ? "..." : "Subscribe"}
</button>
</form>
)}
{status === "error" && (
<p className="newsletter-signup__error">{message}</p>
)}
</div>
</section>
);
}

View File

@@ -4,7 +4,7 @@ import AIChatView from "./AIChatView";
import siteConfig from "../config/siteConfig";
interface RightSidebarProps {
aiChatEnabled?: boolean; // From frontmatter aiChat: true
aiChatEnabled?: boolean; // From frontmatter aiChat: true/false (undefined = not set)
pageContent?: string; // Page markdown content for AI context
slug?: string; // Page/post slug for chat context ID
}
@@ -15,9 +15,15 @@ export default function RightSidebar({
slug,
}: RightSidebarProps) {
// Check if AI chat should be shown
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
// Requires:
// 1. Global config enabled (siteConfig.aiChat.enabledOnContent)
// 2. Frontmatter explicitly enabled (aiChat: true)
// 3. Slug exists for context ID
// If aiChat: false is set in frontmatter, chat will be hidden even if global config is enabled
const showAIChat =
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
siteConfig.aiChat.enabledOnContent &&
aiChatEnabled === true &&
slug;
if (showAIChat) {
return (

View File

@@ -0,0 +1,75 @@
import siteConfig from "../config/siteConfig";
import type { SocialLink } from "../config/siteConfig";
import {
GithubLogo,
TwitterLogo,
LinkedinLogo,
InstagramLogo,
YoutubeLogo,
TiktokLogo,
DiscordLogo,
Globe,
} from "@phosphor-icons/react";
// Map platform names to Phosphor icons
const platformIcons: Record<SocialLink["platform"], React.ComponentType<{ size?: number; weight?: "regular" | "bold" | "fill" }>> = {
github: GithubLogo,
twitter: TwitterLogo,
linkedin: LinkedinLogo,
instagram: InstagramLogo,
youtube: YoutubeLogo,
tiktok: TiktokLogo,
discord: DiscordLogo,
website: Globe,
};
// Social footer component
// Displays social icons on left and copyright on right
// Visibility controlled by siteConfig.socialFooter settings and frontmatter showSocialFooter field
export default function SocialFooter() {
const { socialFooter } = siteConfig;
// Don't render if social footer is globally disabled
if (!socialFooter?.enabled) {
return null;
}
// Get current year for copyright
const currentYear = new Date().getFullYear();
return (
<section className="social-footer">
<div className="social-footer-content">
{/* Social links on the left */}
<div className="social-footer-links">
{socialFooter.socialLinks.map((link) => {
const IconComponent = platformIcons[link.platform];
return (
<a
key={link.platform}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="social-footer-link"
aria-label={`Follow on ${link.platform}`}
>
<IconComponent size={20} weight="regular" />
</a>
);
})}
</div>
{/* Copyright on the right */}
<div className="social-footer-copyright">
<span className="social-footer-copyright-symbol">&copy;</span>
<span className="social-footer-copyright-name">
{socialFooter.copyright.siteName}
</span>
{socialFooter.copyright.showYear && (
<span className="social-footer-copyright-year">{currentYear}</span>
)}
</div>
</div>
</section>
);
}

View File

@@ -114,6 +114,93 @@ export interface AIChatConfig {
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter aiChat: true
}
// Newsletter signup placement configuration
// Controls where signup forms appear on the site
export interface NewsletterSignupPlacement {
enabled: boolean; // Show signup form at this location
position: "above-footer" | "below-intro" | "below-content" | "below-posts";
title: string; // Form heading
description: string; // Form description text
}
// Newsletter configuration (email-only signup)
// Integrates with AgentMail for email collection and sending
// Inbox configured via AGENTMAIL_INBOX environment variable in Convex dashboard
export interface NewsletterConfig {
enabled: boolean; // Master switch for newsletter feature
// Signup form placements
signup: {
home: NewsletterSignupPlacement; // Homepage signup
blogPage: NewsletterSignupPlacement; // Blog page (/blog) signup
posts: NewsletterSignupPlacement; // Individual blog posts (can override via frontmatter)
};
}
// Contact form configuration
// Enables contact forms on pages/posts via frontmatter contactForm: true
// Recipient email configured via AGENTMAIL_CONTACT_EMAIL env var (falls back to AGENTMAIL_INBOX)
export interface ContactFormConfig {
enabled: boolean; // Global toggle for contact form feature
title: string; // Default form title
description: string; // Default form description
}
// Newsletter admin configuration
// Provides admin UI for managing subscribers and sending newsletters
// Access at /newsletter-admin route
export interface NewsletterAdminConfig {
enabled: boolean; // Global toggle for admin UI
showInNav: boolean; // Show link in navigation (hidden by default for security)
}
// Newsletter notifications configuration
// Sends developer notifications for subscriber events
// Uses AGENTMAIL_CONTACT_EMAIL or AGENTMAIL_INBOX as recipient
export interface NewsletterNotificationsConfig {
enabled: boolean; // Global toggle for notifications
newSubscriberAlert: boolean; // Send email when new subscriber signs up
weeklyStatsSummary: boolean; // Send weekly stats summary email
}
// Weekly digest configuration
// Automated weekly email with posts from the past 7 days
export interface WeeklyDigestConfig {
enabled: boolean; // Global toggle for weekly digest
dayOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
subject: string; // Email subject template
}
// Social link configuration for social footer
export interface SocialLink {
platform:
| "github"
| "twitter"
| "linkedin"
| "instagram"
| "youtube"
| "tiktok"
| "discord"
| "website";
url: string; // Full URL (e.g., "https://github.com/username")
}
// Social footer configuration
// Displays social icons on left and copyright on right
// Appears below the main footer on homepage, blog posts, and pages
export interface SocialFooterConfig {
enabled: boolean; // Global toggle for social footer
showOnHomepage: boolean; // Show social footer on homepage
showOnPosts: boolean; // Default: show social footer on blog posts
showOnPages: boolean; // Default: show social footer on static pages
showOnBlogPage: boolean; // Show social footer on /blog page
socialLinks: SocialLink[]; // Array of social links to display
copyright: {
siteName: string; // Site name or company name displayed in copyright
showYear: boolean; // Show auto-updating year (default: true)
};
}
// Site configuration interface
export interface SiteConfig {
// Basic site info
@@ -172,6 +259,24 @@ export interface SiteConfig {
// AI Chat configuration
aiChat: AIChatConfig;
// Newsletter configuration (optional)
newsletter?: NewsletterConfig;
// Contact form configuration (optional)
contactForm?: ContactFormConfig;
// Social footer configuration (optional)
socialFooter?: SocialFooterConfig;
// Newsletter admin configuration (optional)
newsletterAdmin?: NewsletterAdminConfig;
// Newsletter notifications configuration (optional)
newsletterNotifications?: NewsletterNotificationsConfig;
// Weekly digest configuration (optional)
weeklyDigest?: WeeklyDigestConfig;
}
// Default site configuration
@@ -226,6 +331,10 @@ export const siteConfig: SiteConfig = {
src: "/images/logos/agentmail.svg",
href: "https://agentmail.to/utm_source=markdownfast",
},
{
src: "/images/logos/mcp.svg",
href: "https://modelcontextprotocol.io/",
},
],
position: "above-footer",
speed: 30,
@@ -362,6 +471,91 @@ Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Op
enabledOnWritePage: true, // Show AI chat toggle on /write page
enabledOnContent: true, // Allow AI chat on posts/pages via frontmatter aiChat: true
},
// Newsletter configuration (email-only signup)
// Set enabled: true and configure AgentMail to activate
// Requires AGENTMAIL_API_KEY and AGENTMAIL_INBOX environment variables in Convex dashboard
newsletter: {
enabled: true, // Set to true to enable newsletter signup forms
signup: {
home: {
enabled: true,
position: "above-footer",
title: "Stay Updated",
description: "Get new posts delivered to your inbox.",
},
blogPage: {
enabled: true,
position: "above-footer",
title: "Subscribe",
description: "Get notified when new posts are published.",
},
posts: {
enabled: true,
position: "below-content",
title: "Enjoyed this post?",
description: "Subscribe for more updates.",
},
},
},
// Contact form configuration
// Enable via frontmatter contactForm: true on any page or post
// Requires AGENTMAIL_API_KEY and AGENTMAIL_INBOX in Convex dashboard
// Optionally set AGENTMAIL_CONTACT_EMAIL to override recipient (defaults to AGENTMAIL_INBOX)
contactForm: {
enabled: true, // Global toggle for contact form feature
title: "Get in Touch",
description: "Send us a message and we'll get back to you.",
},
// Social footer configuration
// Displays social icons on left and copyright on right
// Can work with or without the main footer
// Use showSocialFooter: false in frontmatter to hide on specific posts/pages
socialFooter: {
enabled: true, // Global toggle for social footer
showOnHomepage: true, // Show social footer on homepage
showOnPosts: true, // Default: show social footer on blog posts
showOnPages: true, // Default: show social footer on static pages
showOnBlogPage: true, // Show social footer on /blog page
socialLinks: [
{
platform: "github",
url: "https://github.com/waynesutton/markdown-site",
},
{ platform: "twitter", url: "https://x.com/waynesutton" },
{ platform: "linkedin", url: "https://www.linkedin.com/in/waynesutton/" },
],
copyright: {
siteName: "MarkDown Sync is open-source", // Update with your site/company name
showYear: true, // Auto-updates to current year
},
},
// Newsletter admin configuration
// Admin UI for managing subscribers and sending newsletters at /newsletter-admin
// Hidden from nav by default (no auth - security through obscurity)
newsletterAdmin: {
enabled: true, // Global toggle for admin UI
showInNav: false, // Hide from navigation for security
},
// Newsletter notifications configuration
// Sends developer notifications for subscriber events via AgentMail
newsletterNotifications: {
enabled: true, // Global toggle for notifications
newSubscriberAlert: true, // Send email when new subscriber signs up
weeklyStatsSummary: true, // Send weekly stats summary email
},
// Weekly digest configuration
// Automated weekly email with posts from the past 7 days
weeklyDigest: {
enabled: true, // Global toggle for weekly digest
dayOfWeek: 0, // Sunday
subject: "Weekly Digest", // Email subject prefix
},
};
// Export the config as default for easy importing

View File

@@ -5,6 +5,8 @@ import { api } from "../../convex/_generated/api";
import PostList from "../components/PostList";
import BlogHeroCard from "../components/BlogHeroCard";
import Footer from "../components/Footer";
import SocialFooter from "../components/SocialFooter";
import NewsletterSignup from "../components/NewsletterSignup";
import siteConfig from "../config/siteConfig";
import { ArrowLeft } from "lucide-react";
@@ -193,6 +195,13 @@ export default function Blog() {
)}
</section>
)}
{/* Newsletter signup (below-posts position) */}
{siteConfig.newsletter?.enabled &&
siteConfig.newsletter.signup.blogPage.enabled &&
siteConfig.newsletter.signup.blogPage.position === "below-posts" && (
<NewsletterSignup source="blog-page" />
)}
{/* Message when posts are disabled on blog page */}
{!showPosts && (
<p className="blog-disabled-message">
@@ -200,8 +209,21 @@ export default function Blog() {
<code>postsDisplay.showOnBlogPage</code> in siteConfig to enable.
</p>
)}
{/* Newsletter signup (above-footer position) */}
{siteConfig.newsletter?.enabled &&
siteConfig.newsletter.signup.blogPage.enabled &&
siteConfig.newsletter.signup.blogPage.position === "above-footer" && (
<NewsletterSignup source="blog-page" />
)}
{/* Footer section */}
{showFooter && <Footer />}
{/* Social footer section */}
{siteConfig.socialFooter?.enabled && siteConfig.socialFooter.showOnBlogPage && (
<SocialFooter />
)}
</div>
);
}

View File

@@ -7,6 +7,8 @@ import FeaturedCards from "../components/FeaturedCards";
import LogoMarquee from "../components/LogoMarquee";
import GitHubContributions from "../components/GitHubContributions";
import Footer from "../components/Footer";
import SocialFooter from "../components/SocialFooter";
import NewsletterSignup from "../components/NewsletterSignup";
import siteConfig from "../config/siteConfig";
// Local storage key for view mode preference
@@ -112,6 +114,13 @@ export default function Home() {
<p className="home-bio">{siteConfig.bio}</p>
{/* Newsletter signup (below-intro position) */}
{siteConfig.newsletter?.enabled &&
siteConfig.newsletter.signup.home.enabled &&
siteConfig.newsletter.signup.home.position === "below-intro" && (
<NewsletterSignup source="home" />
)}
{/* Featured section with optional view toggle */}
{hasFeaturedContent && (
<div className="home-featured">
@@ -223,10 +232,22 @@ export default function Home() {
{/* Logo gallery (above-footer position) */}
{renderLogoGallery("above-footer")}
{/* Newsletter signup (above-footer position) */}
{siteConfig.newsletter?.enabled &&
siteConfig.newsletter.signup.home.enabled &&
siteConfig.newsletter.signup.home.position === "above-footer" && (
<NewsletterSignup source="home" />
)}
{/* Footer section */}
{siteConfig.footer.enabled && siteConfig.footer.showOnHomepage && (
<Footer content={siteConfig.footer.defaultContent} />
)}
{/* Social footer section */}
{siteConfig.socialFooter?.enabled && siteConfig.socialFooter.showOnHomepage && (
<SocialFooter />
)}
</div>
);
}

View File

@@ -0,0 +1,868 @@
import { useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import {
House,
Users,
PaperPlaneTilt,
Trash,
MagnifyingGlass,
Funnel,
CaretLeft,
CaretRight,
Check,
X,
Envelope,
ChartBar,
Copy,
PencilSimple,
ClockCounterClockwise,
TrendUp,
} from "@phosphor-icons/react";
import { Moon, Sun, Cloud } from "lucide-react";
import { Half2Icon } from "@radix-ui/react-icons";
import { useTheme } from "../context/ThemeContext";
import siteConfig from "../config/siteConfig";
// Helper to format timestamps
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatDateTime(timestamp: number): string {
return new Date(timestamp).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// Get theme icon based on current theme
function getThemeIcon(theme: string) {
switch (theme) {
case "dark":
return <Moon size={18} />;
case "light":
return <Sun size={18} />;
case "tan":
return <Half2Icon width={18} height={18} />;
case "cloud":
return <Cloud size={18} />;
default:
return <Sun size={18} />;
}
}
type FilterType = "all" | "subscribed" | "unsubscribed";
type ViewMode =
| "subscribers"
| "send-post"
| "write-email"
| "recent-sends"
| "email-stats";
export default function NewsletterAdmin() {
const { theme, toggleTheme } = useTheme();
// View mode state - controls what shows in main area
const [viewMode, setViewMode] = useState<ViewMode>("subscribers");
// Subscriber list state
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<FilterType>("all");
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// Send post state
const [selectedPost, setSelectedPost] = useState<string>("");
// Write email state
const [customSubject, setCustomSubject] = useState("");
const [customContent, setCustomContent] = useState("");
// Shared send state
const [sendingNewsletter, setSendingNewsletter] = useState(false);
const [sendResult, setSendResult] = useState<{
success: boolean;
message: string;
command?: string;
} | null>(null);
const [copied, setCopied] = useState(false);
// Check if admin is enabled
if (!siteConfig.newsletterAdmin?.enabled) {
return (
<div className="newsletter-admin-disabled">
<h1>Newsletter Admin</h1>
<p>Newsletter admin is disabled in site configuration.</p>
<Link to="/">Back to Home</Link>
</div>
);
}
// Queries
const subscribersData = useQuery(api.newsletter.getAllSubscribers, {
limit: 20,
cursor,
filter,
search: search || undefined,
});
const stats = useQuery(api.newsletter.getNewsletterStats);
const postsForNewsletter = useQuery(api.newsletter.getPostsForNewsletter);
// Mutations
const deleteSubscriber = useMutation(api.newsletter.deleteSubscriber);
const scheduleSendPost = useMutation(
api.newsletter.scheduleSendPostNewsletter,
);
const scheduleSendCustom = useMutation(
api.newsletter.scheduleSendCustomNewsletter,
);
// Handle view mode change
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
setSendResult(null); // Clear results when switching views
}, []);
// Handle search with debounce
const handleSearchChange = useCallback((value: string) => {
setSearch(value);
setCursor(undefined); // Reset pagination on search
}, []);
// Handle filter change
const handleFilterChange = useCallback((newFilter: FilterType) => {
setFilter(newFilter);
setCursor(undefined); // Reset pagination on filter change
}, []);
// Handle delete subscriber
const handleDelete = useCallback(
async (subscriberId: Id<"newsletterSubscribers">) => {
const result = await deleteSubscriber({ subscriberId });
if (result.success) {
setDeleteConfirm(null);
}
},
[deleteSubscriber],
);
// Handle send post newsletter
const handleSendPostNewsletter = useCallback(async () => {
if (!selectedPost) return;
setSendingNewsletter(true);
setSendResult(null);
setCopied(false);
try {
const result = await scheduleSendPost({
postSlug: selectedPost,
siteUrl: window.location.origin,
siteName: siteConfig.name,
});
const command = `npm run newsletter:send ${selectedPost}`;
setSendResult({
success: result.success,
message: result.message,
command: result.success ? command : undefined,
});
} catch {
setSendResult({
success: false,
message: "Failed to send newsletter. Check console for details.",
});
} finally {
setSendingNewsletter(false);
}
}, [selectedPost, scheduleSendPost]);
// Handle send custom newsletter
const handleSendCustomNewsletter = useCallback(async () => {
if (!customSubject.trim() || !customContent.trim()) return;
setSendingNewsletter(true);
setSendResult(null);
setCopied(false);
try {
const result = await scheduleSendCustom({
subject: customSubject,
content: customContent,
siteUrl: window.location.origin,
siteName: siteConfig.name,
});
setSendResult({
success: result.success,
message: result.message,
});
// Clear form on success
if (result.success) {
setCustomSubject("");
setCustomContent("");
}
} catch {
setSendResult({
success: false,
message: "Failed to send newsletter. Check console for details.",
});
} finally {
setSendingNewsletter(false);
}
}, [customSubject, customContent, scheduleSendCustom]);
// Handle copy command to clipboard
const handleCopyCommand = useCallback(async (command: string) => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = command;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, []);
// Pagination handlers
const handleNextPage = useCallback(() => {
if (subscribersData?.nextCursor) {
setCursor(subscribersData.nextCursor);
}
}, [subscribersData?.nextCursor]);
const handlePrevPage = useCallback(() => {
setCursor(undefined); // Reset to first page
}, []);
// Get main area title based on view mode
const getMainTitle = () => {
switch (viewMode) {
case "subscribers":
return "Subscribers";
case "send-post":
return "Send Post";
case "write-email":
return "Write Email";
case "recent-sends":
return "Recent Sends";
case "email-stats":
return "Email Stats";
default:
return "Subscribers";
}
};
// Render main content based on view mode
const renderMainContent = () => {
switch (viewMode) {
case "subscribers":
return renderSubscriberList();
case "send-post":
return renderSendPost();
case "write-email":
return renderWriteEmail();
case "recent-sends":
return renderRecentSends();
case "email-stats":
return renderEmailStats();
default:
return renderSubscriberList();
}
};
// Render subscriber list
const renderSubscriberList = () => (
<>
{/* Filter pills */}
<div className="newsletter-admin-filter-bar">
<Funnel size={16} />
<span className="newsletter-admin-filter-label">
{subscribersData?.totalCount ?? 0} results
</span>
</div>
{/* Subscriber list */}
<div className="newsletter-admin-list">
{!subscribersData ? (
<div className="newsletter-admin-loading">Loading subscribers...</div>
) : subscribersData.subscribers.length === 0 ? (
<div className="newsletter-admin-empty">
{search
? "No subscribers match your search."
: "No subscribers yet."}
</div>
) : (
subscribersData.subscribers.map((subscriber) => (
<div
key={subscriber._id}
className={`newsletter-admin-subscriber ${!subscriber.subscribed ? "unsubscribed" : ""}`}
>
<div className="newsletter-admin-subscriber-info">
<span className="newsletter-admin-subscriber-email">
{subscriber.email}
</span>
<span className="newsletter-admin-subscriber-meta">
{subscriber.subscribed ? (
<span className="newsletter-admin-badge active">
Active
</span>
) : (
<span className="newsletter-admin-badge inactive">
Unsubscribed
</span>
)}
<span className="newsletter-admin-subscriber-source">
via {subscriber.source}
</span>
<span className="newsletter-admin-subscriber-date">
{formatDate(subscriber.subscribedAt)}
</span>
</span>
</div>
<div className="newsletter-admin-subscriber-actions">
{deleteConfirm === subscriber._id ? (
<div className="newsletter-admin-delete-confirm">
<span>Delete?</span>
<button
onClick={() => handleDelete(subscriber._id)}
className="newsletter-admin-delete-yes"
title="Confirm delete"
>
<Check size={16} weight="bold" />
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="newsletter-admin-delete-no"
title="Cancel"
>
<X size={16} weight="bold" />
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(subscriber._id)}
className="newsletter-admin-action-btn delete"
title="Delete subscriber"
>
<Trash size={16} />
</button>
)}
</div>
</div>
))
)}
</div>
{/* Pagination */}
{subscribersData && subscribersData.subscribers.length > 0 && (
<div className="newsletter-admin-pagination">
<button
onClick={handlePrevPage}
disabled={!cursor}
className="newsletter-admin-pagination-btn"
>
<CaretLeft size={16} />
First
</button>
<button
onClick={handleNextPage}
disabled={!subscribersData.nextCursor}
className="newsletter-admin-pagination-btn"
>
Next
<CaretRight size={16} />
</button>
</div>
)}
</>
);
// Render send post form
const renderSendPost = () => (
<div className="newsletter-admin-form-container full-width">
<p className="newsletter-admin-form-desc">
Select a blog post to send as a newsletter to all active subscribers.
</p>
<div className="newsletter-admin-form-group">
<label className="newsletter-admin-label">Select Post</label>
<select
value={selectedPost}
onChange={(e) => setSelectedPost(e.target.value)}
className="newsletter-admin-select"
>
<option value="">Choose a post...</option>
{postsForNewsletter?.map((post) => (
<option key={post.slug} value={post.slug} disabled={post.wasSent}>
{post.title} ({post.date}){post.wasSent ? " - SENT" : ""}
</option>
))}
</select>
</div>
<button
onClick={handleSendPostNewsletter}
disabled={!selectedPost || sendingNewsletter}
className="newsletter-admin-send-btn"
>
{sendingNewsletter ? (
"Sending..."
) : (
<>
<PaperPlaneTilt size={16} />
Send to Subscribers
</>
)}
</button>
{renderSendResult()}
</div>
);
// Render write email form
const renderWriteEmail = () => (
<div className="newsletter-admin-form-container full-width">
<p className="newsletter-admin-form-desc">
Write a custom email to send to all active subscribers. Supports
markdown formatting.
</p>
<div className="newsletter-admin-form-group">
<label className="newsletter-admin-label">Subject</label>
<input
type="text"
value={customSubject}
onChange={(e) => setCustomSubject(e.target.value)}
placeholder="Email subject line..."
className="newsletter-admin-input"
/>
</div>
<div className="newsletter-admin-form-group">
<label className="newsletter-admin-label">Content (Markdown)</label>
<textarea
value={customContent}
onChange={(e) => setCustomContent(e.target.value)}
placeholder="Write your email content here...
Supports markdown:
# Heading
**bold** and *italic*
[link text](url)
- list items"
className="newsletter-admin-textarea"
rows={12}
/>
</div>
<button
onClick={handleSendCustomNewsletter}
disabled={
!customSubject.trim() || !customContent.trim() || sendingNewsletter
}
className="newsletter-admin-send-btn"
>
{sendingNewsletter ? (
"Sending..."
) : (
<>
<PaperPlaneTilt size={16} />
Send to Subscribers
</>
)}
</button>
{renderSendResult()}
</div>
);
// Render recent sends (shows both post and custom emails)
const renderRecentSends = () => (
<div className="newsletter-admin-recent-container full-width">
{!stats ? (
<div className="newsletter-admin-loading">Loading recent sends...</div>
) : stats.recentNewsletters.length === 0 ? (
<div className="newsletter-admin-empty">No newsletters sent yet.</div>
) : (
<div className="newsletter-admin-recent-list-main">
{stats.recentNewsletters.map((newsletter, index) => (
<div
key={`${newsletter.postSlug}-${index}`}
className="newsletter-admin-recent-item-main"
>
<div className="newsletter-admin-recent-info">
<span className="newsletter-admin-recent-slug-main">
{newsletter.type === "custom"
? newsletter.subject || "Custom Email"
: newsletter.postSlug}
</span>
<span className="newsletter-admin-recent-meta-main">
{newsletter.type === "custom" ? (
<span className="newsletter-admin-badge-type custom">
Custom
</span>
) : (
<span className="newsletter-admin-badge-type post">
Post
</span>
)}
Sent to {newsletter.sentCount} subscriber
{newsletter.sentCount !== 1 ? "s" : ""}
</span>
</div>
<span className="newsletter-admin-recent-date">
{formatDateTime(newsletter.sentAt)}
</span>
</div>
))}
</div>
)}
</div>
);
// Render email stats
const renderEmailStats = () => (
<div className="newsletter-admin-email-stats full-width">
{!stats ? (
<div className="newsletter-admin-loading">Loading stats...</div>
) : (
<>
{/* Stats cards */}
<div className="newsletter-admin-stats-cards">
<div className="newsletter-admin-stat-card">
<div className="newsletter-admin-stat-card-icon">
<PaperPlaneTilt size={24} />
</div>
<div className="newsletter-admin-stat-card-content">
<span className="newsletter-admin-stat-card-value">
{stats.totalEmailsSent}
</span>
<span className="newsletter-admin-stat-card-label">
Total Emails Sent
</span>
</div>
</div>
<div className="newsletter-admin-stat-card">
<div className="newsletter-admin-stat-card-icon">
<Envelope size={24} />
</div>
<div className="newsletter-admin-stat-card-content">
<span className="newsletter-admin-stat-card-value">
{stats.totalNewslettersSent}
</span>
<span className="newsletter-admin-stat-card-label">
Newsletters Sent
</span>
</div>
</div>
<div className="newsletter-admin-stat-card">
<div className="newsletter-admin-stat-card-icon">
<Users size={24} />
</div>
<div className="newsletter-admin-stat-card-content">
<span className="newsletter-admin-stat-card-value">
{stats.activeSubscribers}
</span>
<span className="newsletter-admin-stat-card-label">
Active Subscribers
</span>
</div>
</div>
<div className="newsletter-admin-stat-card">
<div className="newsletter-admin-stat-card-icon">
<TrendUp size={24} />
</div>
<div className="newsletter-admin-stat-card-content">
<span className="newsletter-admin-stat-card-value">
{stats.totalSubscribers > 0
? Math.round(
(stats.activeSubscribers / stats.totalSubscribers) *
100,
)
: 0}
%
</span>
<span className="newsletter-admin-stat-card-label">
Retention Rate
</span>
</div>
</div>
</div>
{/* Stats summary */}
<div className="newsletter-admin-stats-summary">
<h3>Summary</h3>
<div className="newsletter-admin-stats-row">
<span>Total Subscribers</span>
<span>{stats.totalSubscribers}</span>
</div>
<div className="newsletter-admin-stats-row">
<span>Active Subscribers</span>
<span>{stats.activeSubscribers}</span>
</div>
<div className="newsletter-admin-stats-row">
<span>Unsubscribed</span>
<span>{stats.unsubscribedCount}</span>
</div>
<div className="newsletter-admin-stats-row">
<span>Newsletters Sent</span>
<span>{stats.totalNewslettersSent}</span>
</div>
<div className="newsletter-admin-stats-row">
<span>Total Emails Sent</span>
<span>{stats.totalEmailsSent}</span>
</div>
</div>
</>
)}
</div>
);
// Render send result message
const renderSendResult = () => {
if (!sendResult) return null;
return (
<div
className={`newsletter-admin-send-result ${sendResult.success ? "success" : "error"}`}
>
<span className="newsletter-admin-result-message">
{sendResult.message}
</span>
{sendResult.command && (
<div className="newsletter-admin-command-row">
<code className="newsletter-admin-command">
{sendResult.command}
</code>
<button
onClick={() => handleCopyCommand(sendResult.command!)}
className="newsletter-admin-copy-btn"
title={copied ? "Copied!" : "Copy command"}
>
{copied ? <Check size={14} weight="bold" /> : <Copy size={14} />}
</button>
</div>
)}
</div>
);
};
return (
<div className="newsletter-admin-layout two-column">
{/* Left Sidebar: Navigation and Stats */}
<aside className="newsletter-admin-sidebar-left">
<div className="newsletter-admin-sidebar-header">
<Link
to="/"
className="newsletter-admin-logo-link"
title="Back to home"
>
<House size={20} weight="regular" />
<span>Home</span>
</Link>
</div>
<nav className="newsletter-admin-nav">
{/* Views section */}
<div className="newsletter-admin-nav-section">
<span className="newsletter-admin-nav-label">Views</span>
<button
onClick={() => {
handleViewModeChange("subscribers");
handleFilterChange("all");
}}
className={`newsletter-admin-nav-item ${viewMode === "subscribers" && filter === "all" ? "active" : ""}`}
>
<Users
size={18}
weight={
viewMode === "subscribers" && filter === "all"
? "fill"
: "regular"
}
/>
<span>All Subscribers</span>
</button>
<button
onClick={() => {
handleViewModeChange("subscribers");
handleFilterChange("subscribed");
}}
className={`newsletter-admin-nav-item ${viewMode === "subscribers" && filter === "subscribed" ? "active" : ""}`}
>
<Check
size={18}
weight={
viewMode === "subscribers" && filter === "subscribed"
? "bold"
: "regular"
}
/>
<span>Active</span>
</button>
<button
onClick={() => {
handleViewModeChange("subscribers");
handleFilterChange("unsubscribed");
}}
className={`newsletter-admin-nav-item ${viewMode === "subscribers" && filter === "unsubscribed" ? "active" : ""}`}
>
<X
size={18}
weight={
viewMode === "subscribers" && filter === "unsubscribed"
? "bold"
: "regular"
}
/>
<span>Unsubscribed</span>
</button>
</div>
{/* Send Newsletter section */}
<div className="newsletter-admin-nav-section">
<span className="newsletter-admin-nav-label">Send Newsletter</span>
<button
onClick={() => handleViewModeChange("send-post")}
className={`newsletter-admin-nav-item ${viewMode === "send-post" ? "active" : ""}`}
>
<PaperPlaneTilt
size={18}
weight={viewMode === "send-post" ? "fill" : "regular"}
/>
<span>Send Post</span>
</button>
<button
onClick={() => handleViewModeChange("write-email")}
className={`newsletter-admin-nav-item ${viewMode === "write-email" ? "active" : ""}`}
>
<PencilSimple
size={18}
weight={viewMode === "write-email" ? "fill" : "regular"}
/>
<span>Write Email</span>
</button>
</div>
{/* History section */}
<div className="newsletter-admin-nav-section">
<span className="newsletter-admin-nav-label">History</span>
<button
onClick={() => handleViewModeChange("recent-sends")}
className={`newsletter-admin-nav-item ${viewMode === "recent-sends" ? "active" : ""}`}
>
<ClockCounterClockwise
size={18}
weight={viewMode === "recent-sends" ? "fill" : "regular"}
/>
<span>Recent Sends</span>
</button>
<button
onClick={() => handleViewModeChange("email-stats")}
className={`newsletter-admin-nav-item ${viewMode === "email-stats" ? "active" : ""}`}
>
<ChartBar
size={18}
weight={viewMode === "email-stats" ? "fill" : "regular"}
/>
<span>Email Stats</span>
</button>
</div>
{/* Actions section */}
<div className="newsletter-admin-nav-section">
<span className="newsletter-admin-nav-label">Actions</span>
<button onClick={toggleTheme} className="newsletter-admin-nav-item">
{getThemeIcon(theme)}
<span>Theme</span>
</button>
</div>
</nav>
{/* Stats section */}
{stats && (
<div className="newsletter-admin-stats">
<div className="newsletter-admin-stat">
<span className="newsletter-admin-stat-value">
{stats.activeSubscribers}
</span>
<span className="newsletter-admin-stat-label">Active</span>
</div>
<div className="newsletter-admin-stat">
<span className="newsletter-admin-stat-value">
{stats.totalSubscribers}
</span>
<span className="newsletter-admin-stat-label">Total</span>
</div>
<div className="newsletter-admin-stat">
<span className="newsletter-admin-stat-value">
{stats.totalNewslettersSent}
</span>
<span className="newsletter-admin-stat-label">Sent</span>
</div>
</div>
)}
</aside>
{/* Main Content Area */}
<main className="newsletter-admin-main">
<div className="newsletter-admin-main-header">
<h1 className="newsletter-admin-main-title">
{viewMode === "subscribers" && (
<Envelope size={24} weight="regular" />
)}
{viewMode === "send-post" && (
<PaperPlaneTilt size={24} weight="regular" />
)}
{viewMode === "write-email" && (
<PencilSimple size={24} weight="regular" />
)}
{viewMode === "recent-sends" && (
<ClockCounterClockwise size={24} weight="regular" />
)}
{viewMode === "email-stats" && (
<ChartBar size={24} weight="regular" />
)}
{getMainTitle()}
</h1>
{/* Search bar in header - only show for subscribers view */}
{viewMode === "subscribers" && (
<div className="newsletter-admin-search">
<MagnifyingGlass size={18} />
<input
type="text"
placeholder="Search by email..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="newsletter-admin-search-input"
/>
</div>
)}
</div>
{renderMainContent()}
</main>
</div>
);
}

View File

@@ -6,6 +6,9 @@ import CopyPageDropdown from "../components/CopyPageDropdown";
import PageSidebar from "../components/PageSidebar";
import RightSidebar from "../components/RightSidebar";
import Footer from "../components/Footer";
import SocialFooter from "../components/SocialFooter";
import NewsletterSignup from "../components/NewsletterSignup";
import ContactForm from "../components/ContactForm";
import { extractHeadings } from "../utils/extractHeadings";
import { useSidebar } from "../context/SidebarContext";
import { format, parseISO } from "date-fns";
@@ -286,13 +289,34 @@ export default function Post({
)}
</header>
<BlogPost content={page.content} />
<BlogPost content={page.content} slug={page.slug} pageType="page" />
{/* Contact form - shown when contactForm: true in frontmatter (only if not inline) */}
{siteConfig.contactForm?.enabled && page.contactForm &&
!page.content.includes("<!-- contactform -->") && (
<ContactForm source={`page:${page.slug}`} />
)}
{/* Newsletter signup - respects frontmatter override (only if not inline) */}
{siteConfig.newsletter?.enabled &&
(page.newsletter !== undefined
? page.newsletter
: siteConfig.newsletter.signup.posts.enabled) &&
!page.content.includes("<!-- newsletter -->") && (
<NewsletterSignup source="post" postSlug={page.slug} />
)}
{/* Footer - shown inside article at bottom for pages */}
{siteConfig.footer.enabled &&
(page.showFooter !== undefined ? page.showFooter : siteConfig.footer.showOnPages) && (
<Footer content={page.footer} />
)}
{/* Social footer - shown inside article at bottom for pages */}
{siteConfig.socialFooter?.enabled &&
(page.showSocialFooter !== undefined ? page.showSocialFooter : siteConfig.socialFooter.showOnPages) && (
<SocialFooter />
)}
</article>
{/* Right sidebar - with optional AI chat support */}
@@ -445,7 +469,7 @@ export default function Post({
)}
</header>
<BlogPost content={post.content} />
<BlogPost content={post.content} slug={post.slug} pageType="post" />
<footer className="post-footer">
<div className="post-share">
@@ -510,6 +534,21 @@ export default function Post({
</ul>
</div>
)}
{/* Newsletter signup - respects frontmatter override (only if not inline) */}
{siteConfig.newsletter?.enabled &&
(post.newsletter !== undefined
? post.newsletter
: siteConfig.newsletter.signup.posts.enabled) &&
!post.content.includes("<!-- newsletter -->") && (
<NewsletterSignup source="post" postSlug={post.slug} />
)}
{/* Contact form - shown when contactForm: true in frontmatter (only if not inline) */}
{siteConfig.contactForm?.enabled && post.contactForm &&
!post.content.includes("<!-- contactform -->") && (
<ContactForm source={`post:${post.slug}`} />
)}
</footer>
{/* Footer - shown inside article at bottom for posts */}
@@ -517,6 +556,12 @@ export default function Post({
(post.showFooter !== undefined ? post.showFooter : siteConfig.footer.showOnPosts) && (
<Footer content={post.footer} />
)}
{/* Social footer - shown inside article at bottom for posts */}
{siteConfig.socialFooter?.enabled &&
(post.showSocialFooter !== undefined ? post.showSocialFooter : siteConfig.socialFooter.showOnPosts) && (
<SocialFooter />
)}
</article>
{/* Right sidebar - with optional AI chat support */}

View File

@@ -97,25 +97,25 @@ export default function Stats() {
},
{
number: "04",
icon: GithubLogo,
title: "GitHub Stars",
value: githubStars ?? "...",
description: "waynesutton/markdown-site",
},
{
number: "05",
icon: BookOpen,
title: "Blog Posts",
value: stats.publishedPosts,
description: "Published posts",
},
{
number: "05",
number: "06",
icon: FileText,
title: "Pages",
value: stats.publishedPages,
description: "Static pages",
},
{
number: "06",
icon: GithubLogo,
title: "GitHub Stars",
value: githubStars ?? "...",
description: "waynesutton/markdown-site",
},
];
return (

72
src/pages/Unsubscribe.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { useSearchParams, Link } from "react-router-dom";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useState, useEffect, useRef } from "react";
// Unsubscribe page component
// Handles newsletter unsubscription via email and token from URL params
export default function Unsubscribe() {
const [searchParams] = useSearchParams();
const email = searchParams.get("email");
const token = searchParams.get("token");
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [message, setMessage] = useState("");
const unsubscribeMutation = useMutation(api.newsletter.unsubscribe);
// Track if we've already attempted unsubscribe to prevent double calls
const hasAttempted = useRef(false);
// Auto-unsubscribe when page loads with valid params
useEffect(() => {
if (email && token && !hasAttempted.current) {
hasAttempted.current = true;
handleUnsubscribe();
}
}, [email, token]);
const handleUnsubscribe = async () => {
if (!email || !token) {
setStatus("error");
setMessage("Invalid unsubscribe link.");
return;
}
setStatus("loading");
try {
const result = await unsubscribeMutation({ email, token });
setStatus(result.success ? "success" : "error");
setMessage(result.message);
} catch {
setStatus("error");
setMessage("Something went wrong. Please try again.");
}
};
return (
<div className="unsubscribe-page">
<h1>Unsubscribe</h1>
{status === "loading" && <p>Processing...</p>}
{status === "success" && (
<>
<p className="unsubscribe-success">{message}</p>
<Link to="/" className="unsubscribe-home-link">
Back to home
</Link>
</>
)}
{status === "error" && <p className="unsubscribe-error">{message}</p>}
{status === "idle" && !email && !token && (
<p>Use the unsubscribe link from your email.</p>
)}
</div>
);
}

View File

@@ -54,7 +54,11 @@ const POST_FIELDS = [
required: false,
example: '"Built with [Convex](https://convex.dev)."',
},
{ name: "showSocialFooter", required: false, example: "true" },
{ name: "aiChat", required: false, example: "true" },
{ name: "blogFeatured", required: false, example: "true" },
{ name: "newsletter", required: false, example: "true" },
{ name: "contactForm", required: false, example: "true" },
];
// Frontmatter field definitions for pages
@@ -83,7 +87,10 @@ const PAGE_FIELDS = [
required: false,
example: '"Built with [Convex](https://convex.dev)."',
},
{ name: "showSocialFooter", required: false, example: "true" },
{ name: "aiChat", required: false, example: "true" },
{ name: "newsletter", required: false, example: "true" },
{ name: "contactForm", required: false, example: "true" },
];
// Generate frontmatter template based on content type

File diff suppressed because it is too large Load Diff