feat: add featured section, logo gallery, Firecrawl import, and API export

Featured Section
- Frontmatter-controlled featured items with featured: true and featuredOrder
- Card view with excerpts and list/card toggle button
- View preference saved to localStorage
- New Convex queries for featured posts and pages with by_featured index

Logo Gallery
- Continuous marquee scroll with clickable logos
- CSS animation, grayscale with color on hover
- Configurable speed, position, and title
- 5 sample logos included

Firecrawl Content Importer
- npm run import <url> scrapes external URLs to markdown drafts
- Creates local files in content/blog/ with frontmatter
- Then sync to dev or prod (no separate import:prod command)

API Enhancements
- New /api/export endpoint for batch content fetching
- AI plugin discovery at /.well-known/ai-plugin.json
- OpenAPI 3.0 spec at /openapi.yaml
- Enhanced llms.txt documentation

Documentation
- AGENTS.md with codebase instructions for AI agents
- Updated all sync vs deploy tables to include import workflow
- Renamed content/pages/changelog.md to changelog-page.md

Technical
- New components: FeaturedCards.tsx, LogoMarquee.tsx
- New script: scripts/import-url.ts
- New dependency: @mendable/firecrawl-js
- Schema updates with featured, featuredOrder, excerpt fields
This commit is contained in:
Wayne Sutton
2025-12-18 12:28:25 -08:00
parent e5b22487ca
commit 87e02d00dc
34 changed files with 3161 additions and 154 deletions

397
AGENTS.md Normal file
View File

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

165
README.md
View File

@@ -13,6 +13,8 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
- Fully responsive design
- Real-time analytics at `/stats`
- Full text search with Command+K shortcut
- Featured section with list/card view toggle
- Logo gallery with continuous marquee scroll
### SEO and Discovery
@@ -27,9 +29,18 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
- `/api/posts` - JSON list of all posts for agents
- `/api/post?slug=xxx` - Single post JSON or markdown
- `/api/export` - Batch export all posts with full content
- `/rss-full.xml` - Full content RSS for LLM ingestion
- `/.well-known/ai-plugin.json` - AI plugin manifest
- `/openapi.yaml` - OpenAPI 3.0 specification
- Copy Page dropdown for sharing to ChatGPT, Claude
### Content Import
- Import external URLs as markdown posts using Firecrawl
- Run `npm run import <url>` to scrape and create draft posts locally
- Then sync to dev or prod with `npm run sync` or `npm run sync:prod`
## Getting Started
### Prerequisites
@@ -92,6 +103,7 @@ published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
image: "/images/my-header.png"
excerpt: "Short text for featured cards"
---
Your markdown content here...
@@ -132,6 +144,85 @@ const siteConfig = {
Replace `public/images/logo.svg` with your own logo file.
## Featured Section
Posts and pages with `featured: true` in frontmatter appear in the featured section.
### Add to Featured
Add these fields to any post or page frontmatter:
```yaml
featured: true
featuredOrder: 1
excerpt: "A short description for the card view."
```
Then run `npm run sync`. No redeploy needed.
| Field | Description |
| --- | --- |
| `featured` | Set `true` to show in featured section |
| `featuredOrder` | Order in featured section (lower = first) |
| `excerpt` | Short description for card view |
### Display Modes
The featured section supports two display modes:
- **List view** (default): Bullet list of links
- **Card view**: Grid of cards with title and excerpt
Users can toggle between views. To change the default:
```typescript
const siteConfig = {
featuredViewMode: "cards", // 'list' or 'cards'
showViewToggle: true, // Allow users to switch views
};
```
## Logo Gallery
The homepage includes a scrolling logo gallery with sample logos. Configure in `siteConfig`:
### Disable the gallery
```typescript
logoGallery: {
enabled: false,
// ...
},
```
### Replace with your own logos
1. Add logo images to `public/images/logos/` (SVG recommended)
2. Update the images array with logos and links:
```typescript
logoGallery: {
enabled: true,
images: [
{ src: "/images/logos/your-logo-1.svg", href: "https://example.com" },
{ src: "/images/logos/your-logo-2.svg", href: "https://anothersite.com" },
],
position: "above-footer", // or "below-featured"
speed: 30, // Seconds for one scroll cycle
title: "Trusted by", // Set to undefined to hide
},
```
Each logo object supports:
- `src`: Path to the logo image (required)
- `href`: URL to link to when clicked (optional)
### Remove sample logos
Delete sample files from `public/images/logos/` and replace the images array with your own logos, or set `enabled: false` to hide the gallery entirely.
The gallery uses CSS animations for smooth infinite scrolling. Logos appear grayscale and colorize on hover.
### Favicon
Replace `public/favicon.svg` with your own icon. The default is a rounded square with the letter "m". Edit the SVG to change the letter or style.
@@ -244,15 +335,16 @@ markdown-site/
## Scripts Reference
| Script | Description |
| --------------------- | -------------------------------------------- |
| `npm run dev` | Start Vite dev server |
| `npm run dev:convex` | Start Convex dev backend |
| `npm run sync` | Sync posts to dev deployment |
| `npm run sync:prod` | Sync posts to production deployment |
| `npm run build` | Build for production |
| `npm run deploy` | Sync + build (for manual deploys) |
| `npm run deploy:prod` | Deploy Convex functions + sync to production |
| Script | Description |
| --------------------- | -------------------------------------------------- |
| `npm run dev` | Start Vite dev server |
| `npm run dev:convex` | Start Convex dev backend |
| `npm run sync` | Sync posts to dev deployment |
| `npm run sync:prod` | Sync posts to production deployment |
| `npm run import` | Import URL as local markdown draft (then sync) |
| `npm run build` | Build for production |
| `npm run deploy` | Sync + build (for manual deploys) |
| `npm run deploy:prod` | Deploy Convex functions + sync to production |
## Tech Stack
@@ -301,16 +393,51 @@ How it works:
## API Endpoints
| Endpoint | Description |
| ------------------------------ | ------------------------------- |
| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with post descriptions |
| `/rss-full.xml` | RSS feed with full post content |
| `/sitemap.xml` | Dynamic XML sitemap |
| `/api/posts` | JSON list of all posts |
| `/api/post?slug=xxx` | Single post as JSON |
| `/api/post?slug=xxx&format=md` | Single post as markdown |
| `/meta/post?slug=xxx` | Open Graph HTML for crawlers |
| Endpoint | Description |
| ------------------------------ | ------------------------------------ |
| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with post descriptions |
| `/rss-full.xml` | RSS feed with full post content |
| `/sitemap.xml` | Dynamic XML sitemap |
| `/api/posts` | JSON list of all posts |
| `/api/post?slug=xxx` | Single post as JSON |
| `/api/post?slug=xxx&format=md` | Single post as markdown |
| `/api/export` | Batch export all posts with content |
| `/meta/post?slug=xxx` | Open Graph HTML for crawlers |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
## Import External Content
Use Firecrawl to import articles from external URLs as markdown posts:
```bash
npm run import https://example.com/article
```
This will:
1. Scrape the URL using Firecrawl API
2. Convert to clean markdown
3. Create a draft post in `content/blog/` locally
4. Add frontmatter with title, description, and today's date
**Setup:**
1. Get an API key from [firecrawl.dev](https://firecrawl.dev)
2. Add to `.env.local`:
```
FIRECRAWL_API_KEY=fc-your-api-key
```
**Why no `npm run import:prod`?** The import command only creates local markdown files. It does not interact with Convex. After importing, sync to your target environment:
- `npm run sync` for development
- `npm run sync:prod` for production
Imported posts are created as drafts (`published: false`). Review, edit, set `published: true`, then sync.
## How Blog Post Slugs Work

View File

@@ -2,7 +2,7 @@
## Current Status
v1.3.0 ready for deployment. Build passes. TypeScript verified.
v1.5.0 ready for deployment. Build passes. TypeScript verified.
## Completed
@@ -36,6 +36,10 @@ v1.3.0 ready for deployment. Build passes. TypeScript verified.
- [x] Real-time search with Command+K shortcut
- [x] Search modal with keyboard navigation
- [x] Full text search indexes for posts and pages
- [x] Featured section with list/card view toggle
- [x] Logo gallery with continuous marquee scroll
- [x] Frontmatter-controlled featured items (featured, featuredOrder)
- [x] Featured items sync with npm run sync (no redeploy needed)
## Deployment Steps

View File

@@ -4,6 +4,104 @@ 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.6.1] - 2025-12-18
### Changed
- Added Firecrawl import to all "When to sync vs deploy" tables in docs
- Clarified import workflow: creates local files only, no `import:prod` needed
- Updated README, setup-guide, how-to-publish, docs page, about-this-blog
- Renamed `content/pages/changelog.md` to `changelog-page.md` to avoid confusion with root changelog
## [1.6.0] - 2025-12-18
### Added
- Firecrawl content importer for external URLs
- New `npm run import <url>` command
- Scrapes URLs and converts to local markdown drafts
- Creates drafts in `content/blog/` with frontmatter
- Uses Firecrawl API (requires `FIRECRAWL_API_KEY` in `.env.local`)
- Then sync to dev (`npm run sync`) or prod (`npm run sync:prod`)
- No separate `import:prod` command needed (import creates local files only)
- New API endpoint `/api/export` for batch content fetching
- Returns all posts with full markdown content
- Single request for LLM ingestion
- AI plugin discovery at `/.well-known/ai-plugin.json`
- Standard format for AI tool integration
- OpenAPI 3.0 specification at `/openapi.yaml`
- Full API documentation
- Describes all endpoints, parameters, and responses
- Enhanced `llms.txt` with complete API documentation
- Added all new endpoints
- Improved quick start section
- Added response schema documentation
### Technical
- New script: `scripts/import-url.ts`
- New package dependency: `@mendable/firecrawl-js`
- Updated `netlify/edge-functions/api.ts` for `/api/export` proxy
- Updated `convex/http.ts` with export endpoint
- Created `public/.well-known/` directory
## [1.5.0] - 2025-12-17
### Added
- Frontmatter-controlled featured items
- Add `featured: true` to any post or page frontmatter
- Use `featuredOrder` to control display order (lower = first)
- Featured items sync instantly with `npm run sync` (no redeploy needed)
- New Convex queries for featured content
- `getFeaturedPosts`: returns posts with `featured: true`
- `getFeaturedPages`: returns pages with `featured: true`
- Schema updates with `featured` and `featuredOrder` fields
- Added `by_featured` index for efficient queries
### Changed
- Home.tsx now queries featured items from Convex instead of siteConfig
- FeaturedCards component uses Convex queries for real-time updates
- Removed hardcoded `featuredItems` and `featuredEssays` from siteConfig
### Technical
- Updated sync script to parse `featured` and `featuredOrder` from frontmatter
- Added index on `featured` field in posts and pages tables
- Both list and card views now use frontmatter data
## [1.4.0] - 2025-12-17
### Added
- Featured section with list/card view toggle
- Card view displays title and excerpt in a responsive grid
- Toggle button in featured header to switch between views
- View preference saved to localStorage
- Logo gallery with continuous marquee scroll
- Clickable logos with configurable URLs
- CSS only animation for smooth infinite scrolling
- Configurable speed, position, and title
- Grayscale logos with color on hover
- Responsive sizing across breakpoints
- 5 sample logos included for easy customization
- New `excerpt` field for posts and pages frontmatter
- Used for card view descriptions
- Falls back to description field for posts
- Expanded `siteConfig` in Home.tsx
- `featuredViewMode`: 'list' or 'cards'
- `showViewToggle`: enable user toggle
- `logoGallery`: full configuration object
### Technical
- New components: `FeaturedCards.tsx`, `LogoMarquee.tsx`
- Updated schema with optional excerpt field
- Updated sync script to parse excerpt from frontmatter
- CSS uses theme variables for all four themes
- Mobile responsive grid (3 to 2 to 1 columns for cards)
## [1.3.0] - 2025-12-17
### Added

View File

@@ -6,6 +6,9 @@ slug: "about-this-blog"
published: true
tags: ["convex", "netlify", "open-source", "markdown"]
readTime: "4 min read"
featured: true
featuredOrder: 3
excerpt: "Learn how this open source site works with real-time sync and instant updates."
---
# About This Markdown Site
@@ -77,6 +80,8 @@ The setup takes about 10 minutes:
**Development vs Production:** Use `npm run sync` when testing locally against your dev Convex deployment. Use `npm run sync:prod` when deploying content to your live production site.
**Import external content:** Run `npm run import <url>` to scrape and create local markdown drafts. Then sync to dev or prod. There is no separate import command for production because import creates local files only.
Read the [setup guide](/setup-guide) for detailed steps.
## Customization

View File

@@ -6,6 +6,9 @@ slug: "how-to-publish"
published: true
tags: ["tutorial", "markdown", "cursor", "publishing"]
readTime: "3 min read"
featured: true
featuredOrder: 2
excerpt: "Quick guide to writing and publishing markdown posts with npm run sync."
---
# How to Publish a Blog Post
@@ -38,16 +41,19 @@ readTime: "5 min read"
---
```
| Field | Required | What It Does |
| ------------- | -------- | ----------------------------------- |
| `title` | Yes | Displays as the post heading |
| `description` | Yes | Shows in search results and sharing |
| `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | Becomes the URL path |
| `published` | Yes | Set `true` to show, `false` to hide |
| `tags` | Yes | Topic labels for the post |
| `readTime` | No | Estimated reading time |
| `image` | No | Open Graph image for social sharing |
| Field | Required | What It Does |
| --------------- | -------- | --------------------------------------- |
| `title` | Yes | Displays as the post heading |
| `description` | Yes | Shows in search results and sharing |
| `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | Becomes the URL path |
| `published` | Yes | Set `true` to show, `false` to hide |
| `tags` | Yes | Topic labels for the post |
| `readTime` | No | Estimated reading time |
| `image` | No | Open Graph image for social sharing |
| `featured` | No | Set `true` to show in featured section |
| `featuredOrder` | No | Order in featured section (lower first) |
| `excerpt` | No | Short description for card view |
## Write Your Content
@@ -200,6 +206,88 @@ Your page content here...
The page will appear in the navigation. Use `order` to control the display sequence (lower numbers appear first).
## Sync vs Deploy
Not all changes use `npm run sync`. Here's when to sync vs redeploy:
| 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) |
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
| Logo gallery config | Redeploy | Requires rebuild |
| React components/styles | Redeploy | Requires rebuild |
**Markdown content** syncs instantly via Convex. **Source code changes** (like siteConfig) require pushing to GitHub so Netlify rebuilds.
## Adding to Featured Section
To show a post or page in the homepage featured section, add these fields to frontmatter:
```yaml
featured: true
featuredOrder: 1
excerpt: "A short description for the card view."
```
Then run `npm run sync`. The item appears in the featured section instantly. No redeploy needed.
| Field | Description |
| --------------- | ----------------------------------------- |
| `featured` | Set `true` to show in featured section |
| `featuredOrder` | Order in featured section (lower = first) |
| `excerpt` | Short text shown on card view |
## Updating siteConfig
To change the logo gallery or site info, edit `src/pages/Home.tsx`:
```typescript
const siteConfig = {
name: "Your Site Name",
title: "Your Tagline",
// Featured section display options
featuredViewMode: "cards", // 'list' or 'cards'
showViewToggle: true, // Let users switch between views
// Logo gallery
logoGallery: {
enabled: true,
images: [
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
{ src: "/images/logos/logo2.svg", href: "https://another.com" },
],
position: "above-footer",
speed: 30,
title: "Trusted by",
},
};
```
After editing siteConfig, push to GitHub. Netlify will rebuild automatically.
## Import External Content
You can also import articles from external URLs using Firecrawl:
```bash
npm run import https://example.com/article
```
This creates a draft markdown file in `content/blog/` locally. It does not push to Convex directly.
**After importing:**
- Run `npm run sync` to push to development
- Run `npm run sync:prod` to push to production
There is no `npm run import:prod` because the import step only creates local files. The sync step handles pushing to your target environment.
**Setup:** Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local`. Get a key from [firecrawl.dev](https://firecrawl.dev).
## Summary
Publishing is three steps:

View File

@@ -0,0 +1,92 @@
---
title: "New features: search, featured section, and logo gallery"
description: "Three updates that make your markdown site more useful: Command+K search, frontmatter-controlled featured items, and a scrolling logo gallery."
date: "2025-12-17"
slug: "new-features-search-featured-logos"
published: true
tags: ["features", "search", "convex", "updates"]
readTime: "4 min read"
featured: true
featuredOrder: 0
excerpt: "Search your site with Command+K. Control featured items from frontmatter. Add a logo gallery."
---
# New features: search, featured section, and logo gallery
Three updates shipped today. Each one makes your site more useful without adding complexity.
## Search with Command+K
Press Command+K (or Ctrl+K on Windows) to open search. Start typing. Results appear as you type.
The search finds matches in titles and content across all posts and pages. Title matches show first. Each result includes a snippet with context around the match.
Navigate with arrow keys. Press Enter to go. Press Escape to close.
Search uses Convex full text indexes. Results are reactive. If you publish a new post while the modal is open, it shows up in results immediately.
## Featured section from frontmatter
The homepage featured section now pulls from your markdown files. No more editing siteConfig to change what appears.
Add this to any post or page frontmatter:
```yaml
featured: true
featuredOrder: 1
excerpt: "Short description for card view."
```
Run `npm run sync`. The item appears in featured. No redeploy needed.
Lower numbers appear first. Posts and pages sort together. If two items have the same order, they sort alphabetically.
The toggle button lets visitors switch between list view and card view. Card view shows the excerpt. List view shows just titles.
## Logo gallery
A scrolling marquee of logos now sits above the footer. Good for showing partners, customers, or tools you use.
Configure it in siteConfig:
```typescript
logoGallery: {
enabled: true,
images: [
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
{ src: "/images/logos/logo2.svg" },
],
position: "above-footer",
speed: 30,
title: "Trusted by",
},
```
Each logo can link to a URL. Set `href` to make it clickable. Leave it out for a static logo.
The gallery uses CSS animations. No JavaScript. Logos display in grayscale and colorize on hover.
Five sample logos are included. Replace them with your own in `public/images/logos/`.
## What syncs vs what deploys
Quick reference:
| Change | Command | Speed |
| ------------------- | -------------------------- | -------------- |
| Blog posts | `npm run sync` | Instant |
| Pages | `npm run sync` | Instant |
| Featured items | `npm run sync` | Instant |
| Import external URL | `npm run import` then sync | Instant |
| Logo gallery config | Redeploy | Requires build |
| siteConfig changes | Redeploy | Requires build |
Markdown content syncs instantly through Convex. Source code changes need a push to GitHub so Netlify rebuilds.
## Try it
1. Press Command+K right now. Search for "setup" or "publish".
2. Check the featured section on the homepage. Toggle between views.
3. Look at the logo gallery above the footer.
All three features work with every theme. Dark, light, tan, cloud.

View File

@@ -6,6 +6,9 @@ slug: "setup-guide"
published: true
tags: ["convex", "netlify", "tutorial", "deployment"]
readTime: "8 min read"
featured: true
featuredOrder: 1
excerpt: "Complete guide to fork, set up, and deploy your own markdown blog in under 10 minutes."
---
# Fork and Deploy Your Own Markdown Blog
@@ -37,18 +40,25 @@ This guide walks you through forking [this markdown site](https://github.com/way
- [Adding Images](#adding-images)
- [Sync After Adding Posts](#sync-after-adding-posts)
- [Environment Files](#environment-files)
- [When to Sync vs Deploy](#when-to-sync-vs-deploy)
- [Customizing Your Blog](#customizing-your-blog)
- [Change the Favicon](#change-the-favicon)
- [Change the Site Logo](#change-the-site-logo)
- [Change the Default Open Graph Image](#change-the-default-open-graph-image)
- [Update Site Configuration](#update-site-configuration)
- [Featured Section](#featured-section)
- [Logo Gallery](#logo-gallery)
- [Change the Default Theme](#change-the-default-theme)
- [Change the Font](#change-the-font)
- [Add Static Pages (Optional)](#add-static-pages-optional)
- [Update SEO Meta Tags](#update-seo-meta-tags)
- [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt)
- [Search](#search)
- [Using Search](#using-search)
- [How It Works](#how-it-works)
- [Real-time Stats](#real-time-stats)
- [API Endpoints](#api-endpoints)
- [Import External Content](#import-external-content)
- [Troubleshooting](#troubleshooting)
- [Posts not appearing](#posts-not-appearing)
- [RSS/Sitemap not working](#rsssitemap-not-working)
@@ -116,10 +126,30 @@ export default defineSchema({
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
lastSyncedAt: v.optional(v.number()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
.index("by_published", ["published"])
.index("by_featured", ["featured"]),
pages: defineTable({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"]),
viewCounts: defineTable({
slug: v.string(),
@@ -266,16 +296,19 @@ Your markdown content here...
### Frontmatter Fields
| Field | Required | Description |
| ------------- | -------- | ----------------------------- |
| `title` | Yes | Post title |
| `description` | Yes | Short description for SEO |
| `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | URL path (must be unique) |
| `published` | Yes | Set to `true` to publish |
| `tags` | Yes | Array of topic tags |
| `readTime` | No | Estimated reading time |
| `image` | No | Header/Open Graph image URL |
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------- |
| `title` | Yes | Post title |
| `description` | Yes | Short description for SEO |
| `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | URL path (must be unique) |
| `published` | Yes | Set to `true` to publish |
| `tags` | Yes | Array of topic tags |
| `readTime` | No | Estimated reading time |
| `image` | No | Header/Open Graph image URL |
| `excerpt` | No | Short excerpt for card view |
| `featured` | No | Set `true` to show in featured section |
| `featuredOrder` | No | Order in featured section (lower = first) |
### Adding Images
@@ -336,6 +369,22 @@ npm run sync:prod
Both files are gitignored. Each developer creates their own local environment files.
### 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) |
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
| Logo gallery config | Redeploy | Requires rebuild |
| React components/styles | Redeploy | Requires rebuild |
**Markdown content** syncs instantly via Convex. **Source code changes** require pushing to GitHub for Netlify to rebuild.
**Featured items** can now be controlled via markdown frontmatter. Add `featured: true` and `featuredOrder: 1` to any post or page, then run `npm run sync`.
## Customizing Your Blog
### Change the Favicon
@@ -386,14 +435,114 @@ const siteConfig = {
title: "Your Title",
intro: "Your introduction...",
bio: "Your bio...",
// Featured section options
featuredViewMode: "list", // 'list' or 'cards'
showViewToggle: true, // Let users switch between views
featuredItems: [
{ slug: "post-slug", type: "post" },
{ slug: "page-slug", type: "page" },
],
featuredEssays: [{ title: "Post Title", slug: "post-slug" }],
// Logo gallery (marquee scroll with clickable links)
logoGallery: {
enabled: true, // Set false to hide
images: [
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
{ src: "/images/logos/logo2.svg", href: "https://another.com" },
],
position: "above-footer", // or 'below-featured'
speed: 30, // Seconds for one scroll cycle
title: "Trusted by",
},
links: {
github: "https://github.com/waynesutton/markdown-site",
twitter: "https://twitter.com/yourusername",
docs: "/setup-guide",
convex: "https://convex.dev",
},
};
```
### Featured Section
The homepage featured section shows posts and pages marked with `featured: true` in their frontmatter. It supports two display modes:
1. **List view** (default): Bullet list of links
2. **Card view**: Grid of cards showing title and excerpt
**Add a post to featured section:**
Add these fields to any post or page frontmatter:
```yaml
featured: true
featuredOrder: 1
excerpt: "A short description that appears on the card."
```
Then run `npm run sync`. The post appears in the featured section instantly. No redeploy needed.
**Order featured items:**
Use `featuredOrder` to control display order. Lower numbers appear first. Posts and pages are sorted together.
**Toggle view mode:**
Users can toggle between list and card views using the icon button next to "Get started:". To change the default view, set `featuredViewMode: "cards"` in siteConfig.
### Logo Gallery
The homepage includes a scrolling logo gallery with 5 sample logos. Customize or disable it in siteConfig:
**Disable the gallery:**
```typescript
logoGallery: {
enabled: false, // Set to false to hide
// ...
},
```
**Replace with your own logos:**
1. Add your logo images to `public/images/logos/` (SVG recommended)
2. Update the images array with your logos and links:
```typescript
logoGallery: {
enabled: true,
images: [
{ src: "/images/logos/your-logo-1.svg", href: "https://example.com" },
{ src: "/images/logos/your-logo-2.svg", href: "https://anothersite.com" },
],
position: "above-footer",
speed: 30,
title: "Trusted by",
},
```
Each logo object supports:
- `src`: Path to the logo image (required)
- `href`: URL to link to when clicked (optional)
**Remove sample logos:**
Delete the sample files from `public/images/logos/` and clear the images array, or replace them with your own.
**Configuration options:**
| Option | Description |
| ---------- | ---------------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `images` | Array of logo objects with `src` and optional `href` |
| `position` | `'above-footer'` or `'below-featured'` |
| `speed` | Seconds for one scroll cycle (lower = faster) |
| `title` | Text above gallery (set to `undefined` to hide) |
The gallery uses CSS animations for smooth infinite scrolling. Logos display in grayscale and colorize on hover.
### Change the Default Theme
Edit `src/context/ThemeContext.tsx`:
@@ -464,6 +613,29 @@ Edit `index.html` to update:
Edit `public/llms.txt` and `public/robots.txt` with your site information.
## Search
Your blog includes full text search with Command+K keyboard shortcut.
### Using Search
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. You can also click the search icon in the top navigation.
**Features:**
- Real-time results as you type
- Keyboard navigation with arrow keys
- Press Enter to select, Escape to close
- Result snippets with context around matches
- Distinguishes between posts and pages with type badges
- Works with all four themes
### How It Works
Search uses Convex full text search indexes on the posts and pages tables. The search queries both title and content fields, deduplicates results, and sorts with title matches first.
Search is automatically available once you deploy. No additional configuration needed.
## Real-time Stats
Your blog includes a real-time analytics page at `/stats`:
@@ -496,6 +668,40 @@ Your blog includes these API endpoints for search engines and AI:
| `/api/posts` | JSON list of all posts |
| `/api/post?slug=xxx` | Single post as JSON |
| `/api/post?slug=xxx&format=md` | Single post as raw markdown |
| `/api/export` | Batch export all posts |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
## Import External Content
Use Firecrawl to import articles from external URLs as markdown posts:
```bash
npm run import https://example.com/article
```
**Setup:**
1. Get an API key from [firecrawl.dev](https://firecrawl.dev)
2. Add to `.env.local`:
```
FIRECRAWL_API_KEY=fc-your-api-key
```
The import script will:
1. Scrape the URL and convert to markdown
2. Create a draft post in `content/blog/` locally
3. Extract title and description from the page
**Why no `npm run import:prod`?** The import command only creates local markdown files. It does not interact with Convex directly. After importing:
- Run `npm run sync` to push to development
- Run `npm run sync:prod` to push to production
Imported posts are created as drafts (`published: false`). Review, edit, set `published: true`, then sync to your target environment.
## Troubleshooting

View File

@@ -3,6 +3,7 @@ title: "About"
slug: "about"
published: true
order: 1
excerpt: "A markdown site built for writers, developers, and teams who want a fast, real-time publishing workflow."
---
This is a markdown site built for writers, developers, and teams who want a fast, real-time publishing workflow.
@@ -23,6 +24,17 @@ The backend runs on Convex, a reactive database that pushes updates to clients i
| Hosting | Netlify |
| Content | Markdown |
## Features
- Four theme options (dark, light, tan, cloud)
- Full text search with Command+K shortcut
- Featured section with list/card view toggle and excerpts
- Logo gallery with clickable links and marquee scroll
- Real-time analytics at `/stats`
- RSS feeds and sitemap for SEO
- API endpoints for AI/LLM access
- Copy to ChatGPT/Claude sharing
## Who this is for
Writers who want version control for their content. Developers who want to extend the platform. Teams who need real-time collaboration without a traditional CMS.

View File

@@ -0,0 +1,160 @@
---
title: "Changelog"
slug: "changelog"
published: true
order: 5
---
# Changelog
All notable changes to this project.
## v1.6.1
Released December 18, 2025
**Documentation updates**
- Added Firecrawl import to all "When to sync vs deploy" tables
- Clarified import workflow: creates local files only, no `import:prod` needed
- Updated docs: README, setup-guide, how-to-publish, docs page, about-this-blog
- Renamed `content/pages/changelog.md` to `changelog-page.md` to avoid confusion with root changelog
## v1.6.0
Released December 18, 2025
**Content import and LLM API enhancements**
- Firecrawl content importer for external URLs
- `npm run import <url>` scrapes and creates local markdown drafts
- Creates drafts in `content/blog/` with frontmatter
- Then sync to dev (`npm run sync`) or prod (`npm run sync:prod`)
- No separate `import:prod` command (import creates local files only)
- New `/api/export` endpoint for batch content fetching
- AI plugin discovery at `/.well-known/ai-plugin.json`
- OpenAPI 3.0 specification at `/openapi.yaml`
- Enhanced `llms.txt` with complete API documentation
New dependencies: `@mendable/firecrawl-js`
New files: `scripts/import-url.ts`, `public/.well-known/ai-plugin.json`, `public/openapi.yaml`
## v1.5.0
Released December 17, 2025
**Frontmatter-controlled featured items**
- Add `featured: true` to any post or page frontmatter
- Use `featuredOrder` to control display order (lower = first)
- Featured items sync instantly with `npm run sync` (no redeploy needed)
New Convex queries:
- `getFeaturedPosts`: returns posts with `featured: true`
- `getFeaturedPages`: returns pages with `featured: true`
Schema updates with `featured` and `featuredOrder` fields and `by_featured` index.
## v1.4.0
Released December 17, 2025
**Featured section with list/card view toggle**
- Card view displays title and excerpt in a responsive grid
- Toggle button in featured header to switch between views
- View preference saved to localStorage
**Logo gallery with continuous marquee scroll**
- Clickable logos with configurable URLs
- CSS only animation for smooth infinite scrolling
- Configurable speed, position, and title
- Grayscale logos with color on hover
- Responsive sizing across breakpoints
- 5 sample logos included
**New frontmatter field**
- `excerpt` field for posts and pages
- Used for card view descriptions
- Falls back to description field for posts
## v1.3.0
Released December 17, 2025
**Real-time search with Command+K**
- Search icon in top nav using Phosphor Icons
- Modal with keyboard navigation (arrow keys, Enter, Escape)
- Full text search across posts and pages using Convex search indexes
- Result snippets with context around search matches
- Distinguishes between posts and pages with type badges
Search uses Convex full text search with reactive queries. Results deduplicate from title and content searches. Title matches sort first.
## v1.2.0
Released December 15, 2025
**Real-time stats page at /stats**
- Active visitors count with per-page breakdown
- Total page views and unique visitors
- Views by page sorted by popularity
Page view tracking via event records pattern (no write conflicts). Active session heartbeat system with 30s interval and 2min timeout. Cron job for stale session cleanup every 5 minutes.
New Convex tables: `pageViews` and `activeSessions`.
## v1.1.0
Released December 14, 2025
**Netlify Edge Functions for dynamic Convex HTTP proxying**
- `rss.ts` proxies `/rss.xml` and `/rss-full.xml`
- `sitemap.ts` proxies `/sitemap.xml`
- `api.ts` proxies `/api/posts` and `/api/post`
Vite dev server proxy for RSS, sitemap, and API endpoints. Edge functions dynamically read `VITE_CONVEX_URL` from environment.
## v1.0.0
Released December 14, 2025
**Initial release**
- Markdown blog posts with frontmatter parsing
- Static pages support (About, Projects, Contact)
- Four theme options: Dark, Light, Tan (default), Cloud
- Syntax highlighting for code blocks
- Year-grouped post list on home page
- Individual post pages with share buttons
**SEO and discovery**
- Dynamic sitemap at `/sitemap.xml`
- JSON-LD structured data for blog posts
- RSS feeds at `/rss.xml` and `/rss-full.xml`
- AI agent discovery with `llms.txt`
- `robots.txt` with rules for AI crawlers
**API endpoints**
- `/api/posts` for JSON list of all posts
- `/api/post?slug=xxx` for single post as JSON or markdown
**Copy Page dropdown** for sharing to ChatGPT and Claude.
**Technical stack**
- React 18 with TypeScript
- Convex for real-time database
- react-markdown for rendering
- react-syntax-highlighter for code blocks
- Netlify deployment with edge functions

View File

@@ -81,16 +81,19 @@ image: "/images/og-image.png"
Content here...
```
| Field | Required | Description |
| ------------- | -------- | --------------------- |
| `title` | Yes | Post title |
| `description` | Yes | SEO description |
| `date` | Yes | YYYY-MM-DD format |
| `slug` | Yes | URL path (unique) |
| `published` | Yes | `true` to show |
| `tags` | Yes | Array of strings |
| `readTime` | No | Display time estimate |
| `image` | No | Open Graph image |
| Field | Required | Description |
| --------------- | -------- | ------------------------------------- |
| `title` | Yes | Post title |
| `description` | Yes | SEO description |
| `date` | Yes | YYYY-MM-DD format |
| `slug` | Yes | URL path (unique) |
| `published` | Yes | `true` to show |
| `tags` | Yes | Array of strings |
| `readTime` | No | Display time estimate |
| `image` | No | Open Graph image |
| `excerpt` | No | Short text for card view |
| `featured` | No | `true` to show in featured section |
| `featuredOrder` | No | Order in featured (lower = first) |
### Static pages
@@ -107,12 +110,15 @@ order: 1
Content here...
```
| Field | Required | Description |
| ----------- | -------- | ------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
| Field | Required | Description |
| --------------- | -------- | ------------------------------------- |
| `title` | Yes | Nav link text |
| `slug` | Yes | URL path |
| `published` | Yes | `true` to show |
| `order` | No | Nav order (lower = first) |
| `excerpt` | No | Short text for card view |
| `featured` | No | `true` to show in featured section |
| `featuredOrder` | No | Order in featured (lower = first) |
### Syncing content
@@ -124,6 +130,20 @@ npm run sync
npm run sync:prod
```
### 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) |
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
| Logo gallery config | Redeploy | Requires rebuild |
| React components/styles | Redeploy | Requires rebuild |
**Markdown content** syncs instantly. **Source code** requires pushing to GitHub for Netlify to rebuild.
## Configuration
### Site settings
@@ -137,7 +157,24 @@ const siteConfig = {
logo: "/images/logo.svg", // null to hide
intro: "Introduction text...",
bio: "Bio text...",
// Featured section
featuredViewMode: "list", // 'list' or 'cards'
showViewToggle: true,
featuredItems: [{ slug: "post-slug", type: "post" }],
featuredEssays: [{ title: "Post Title", slug: "post-slug" }],
// Logo gallery (with clickable links)
logoGallery: {
enabled: true, // false to hide
images: [
{ src: "/images/logos/logo.svg", href: "https://example.com" },
],
position: "above-footer",
speed: 30,
title: "Trusted by",
},
links: {
docs: "/docs",
convex: "https://convex.dev",
@@ -145,6 +182,73 @@ const siteConfig = {
};
```
### Featured items
Posts and pages appear in the featured section when marked with `featured: true` in frontmatter.
**Add to featured section:**
```yaml
# In any post or page frontmatter
featured: true
featuredOrder: 1
excerpt: "Short description for card view."
```
Then run `npm run sync`. No redeploy needed.
| Field | Description |
| --- | --- |
| `featured` | Set `true` to show in featured section |
| `featuredOrder` | Order in featured section (lower = first) |
| `excerpt` | Short text shown on card view |
**Display options (in siteConfig):**
```typescript
// In src/pages/Home.tsx
const siteConfig = {
featuredViewMode: "list", // 'list' or 'cards'
showViewToggle: true, // Let users switch views
};
```
### Logo gallery
The homepage includes a scrolling logo marquee with sample logos. Each logo can link to a URL.
```typescript
// In src/pages/Home.tsx
logoGallery: {
enabled: true, // false to hide
images: [
{ src: "/images/logos/logo1.svg", href: "https://example.com" },
{ src: "/images/logos/logo2.svg", href: "https://another.com" },
],
position: "above-footer", // or 'below-featured'
speed: 30, // Seconds for one scroll cycle
title: "Trusted by", // undefined to hide
},
```
| Option | Description |
| --- | --- |
| `enabled` | `true` to show, `false` to hide |
| `images` | Array of `{ src, href }` objects |
| `position` | `'above-footer'` or `'below-featured'` |
| `speed` | Seconds for one scroll cycle (lower = faster) |
| `title` | Text above gallery (`undefined` to hide) |
**To add logos:**
1. Add SVG/PNG files to `public/images/logos/`
2. Update the `images` array with `src` paths and `href` URLs
3. Push to GitHub (requires rebuild)
**To disable:** Set `enabled: false`
**To remove samples:** Delete files from `public/images/logos/` or clear the images array.
### Theme
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
@@ -179,6 +283,20 @@ body {
| Default OG image | `public/images/og-default.svg` | 1200x630 |
| Post images | `public/images/` | Any |
## Search
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. Click the search icon in the nav or use the keyboard shortcut.
**Features:**
- Real-time results as you type
- Keyboard navigation (arrow keys, Enter, Escape)
- Result snippets with context around matches
- Distinguishes between posts and pages
- Works with all four themes
Search uses Convex full text search indexes. No configuration needed.
## Real-time stats
The `/stats` page displays real-time analytics:
@@ -192,15 +310,43 @@ All stats update automatically via Convex subscriptions.
## API endpoints
| Endpoint | Description |
| ------------------------------ | ----------------------- |
| `/stats` | Real-time analytics |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |
| `/api/posts` | JSON post list |
| `/api/post?slug=xxx` | Single post (JSON) |
| `/api/post?slug=xxx&format=md` | Single post (markdown) |
| Endpoint | Description |
| ------------------------------ | ----------------------------- |
| `/stats` | Real-time analytics |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |
| `/api/posts` | JSON post list |
| `/api/post?slug=xxx` | Single post (JSON) |
| `/api/post?slug=xxx&format=md` | Single post (markdown) |
| `/api/export` | All posts with full content |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
## Import external content
Use Firecrawl to import articles from external URLs:
```bash
npm run import https://example.com/article
```
Setup:
1. Get an API key from firecrawl.dev
2. Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local`
The import command creates local markdown files only. It does not interact with Convex directly.
**After importing:**
- `npm run sync` to push to development
- `npm run sync:prod` to push to production
There is no `npm run import:prod` because import creates local files and sync handles the target environment.
Imported posts are drafts (`published: false`). Review, edit, set `published: true`, then sync.
## Deployment
@@ -240,10 +386,14 @@ export default defineSchema({
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()), // For card view
featured: v.optional(v.boolean()), // Show in featured section
featuredOrder: v.optional(v.number()), // Order in featured (lower = first)
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
.index("by_published", ["published"])
.index("by_featured", ["featured"]),
pages: defineTable({
slug: v.string(),
@@ -251,10 +401,14 @@ export default defineSchema({
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
excerpt: v.optional(v.string()), // For card view
featured: v.optional(v.boolean()), // Show in featured section
featuredOrder: v.optional(v.number()), // Order in featured (lower = first)
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
.index("by_published", ["published"])
.index("by_featured", ["featured"]),
});
```

View File

@@ -165,6 +165,51 @@ ${post.content}`;
}),
});
// API endpoint: Export all posts with full content (batch for LLMs)
http.route({
path: "/api/export",
method: "GET",
handler: httpAction(async (ctx) => {
const posts = await ctx.runQuery(api.posts.getAllPosts);
// Fetch full content for each post
const fullPosts = await Promise.all(
posts.map(async (post) => {
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
slug: post.slug,
});
return {
title: post.title,
slug: post.slug,
description: post.description,
date: post.date,
readTime: post.readTime,
tags: post.tags,
url: `${SITE_URL}/${post.slug}`,
content: fullPost?.content || "",
};
}),
);
const response = {
site: SITE_NAME,
url: SITE_URL,
description: "Open source markdown blog with real-time sync.",
exportedAt: new Date().toISOString(),
totalPosts: fullPosts.length,
posts: fullPosts,
};
return new Response(JSON.stringify(response, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=300, s-maxage=600",
"Access-Control-Allow-Origin": "*",
},
});
}),
});
// Escape HTML characters to prevent XSS
function escapeHtml(text: string): string {
return text

View File

@@ -11,6 +11,9 @@ export const getAllPages = query({
title: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
@@ -33,6 +36,46 @@ export const getAllPages = query({
title: page.title,
published: page.published,
order: page.order,
excerpt: page.excerpt,
featured: page.featured,
featuredOrder: page.featuredOrder,
}));
},
});
// Get featured pages for the homepage featured section
export const getFeaturedPages = query({
args: {},
returns: v.array(
v.object({
_id: v.id("pages"),
slug: v.string(),
title: v.string(),
excerpt: v.optional(v.string()),
featuredOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
const pages = await ctx.db
.query("pages")
.withIndex("by_featured", (q) => q.eq("featured", true))
.collect();
// Filter to only published pages and sort by featuredOrder
const featuredPages = pages
.filter((p) => p.published)
.sort((a, b) => {
const orderA = a.featuredOrder ?? 999;
const orderB = b.featuredOrder ?? 999;
return orderA - orderB;
});
return featuredPages.map((page) => ({
_id: page._id,
slug: page.slug,
title: page.title,
excerpt: page.excerpt,
featuredOrder: page.featuredOrder,
}));
},
});
@@ -50,6 +93,9 @@ export const getPageBySlug = query({
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
v.null(),
),
@@ -70,6 +116,9 @@ export const getPageBySlug = query({
content: page.content,
published: page.published,
order: page.order,
excerpt: page.excerpt,
featured: page.featured,
featuredOrder: page.featuredOrder,
};
},
});
@@ -84,6 +133,9 @@ export const syncPagesPublic = mutation({
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
),
},
@@ -115,6 +167,9 @@ export const syncPagesPublic = mutation({
content: page.content,
published: page.published,
order: page.order,
excerpt: page.excerpt,
featured: page.featured,
featuredOrder: page.featuredOrder,
lastSyncedAt: now,
});
updated++;

View File

@@ -16,6 +16,9 @@ export const getAllPosts = query({
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
@@ -41,6 +44,48 @@ export const getAllPosts = query({
tags: post.tags,
readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
}));
},
});
// Get featured posts for the homepage featured section
export const getFeaturedPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
excerpt: v.optional(v.string()),
description: v.string(),
featuredOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_featured", (q) => q.eq("featured", true))
.collect();
// Filter to only published posts and sort by featuredOrder
const featuredPosts = posts
.filter((p) => p.published)
.sort((a, b) => {
const orderA = a.featuredOrder ?? 999;
const orderB = b.featuredOrder ?? 999;
return orderA - orderB;
});
return featuredPosts.map((post) => ({
_id: post._id,
slug: post.slug,
title: post.title,
excerpt: post.excerpt,
description: post.description,
featuredOrder: post.featuredOrder,
}));
},
});
@@ -63,6 +108,9 @@ export const getPostBySlug = query({
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
v.null(),
),
@@ -88,6 +136,9 @@ export const getPostBySlug = query({
tags: post.tags,
readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
};
},
});
@@ -106,6 +157,9 @@ export const syncPosts = internalMutation({
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
),
},
@@ -141,6 +195,9 @@ export const syncPosts = internalMutation({
tags: post.tags,
readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
lastSyncedAt: now,
});
updated++;
@@ -180,6 +237,9 @@ export const syncPostsPublic = mutation({
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
}),
),
},
@@ -215,6 +275,9 @@ export const syncPostsPublic = mutation({
tags: post.tags,
readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
featured: post.featured,
featuredOrder: post.featuredOrder,
lastSyncedAt: now,
});
updated++;

View File

@@ -13,11 +13,15 @@ export default defineSchema({
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()), // Header/OG image URL
excerpt: v.optional(v.string()), // Short excerpt for card view
featured: v.optional(v.boolean()), // Show in featured section
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_date", ["date"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],
@@ -34,10 +38,14 @@ export default defineSchema({
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()), // Display order in nav
excerpt: v.optional(v.string()), // Short excerpt for card view
featured: v.optional(v.boolean()), // Show in featured section
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],

131
files.md
View File

@@ -12,6 +12,7 @@ A brief description of each file in the codebase.
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
| `README.md` | Project documentation |
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
| `files.md` | This file - codebase structure |
| `changelog.md` | Version history and changes |
| `TASK.md` | Task tracking and project status |
@@ -30,7 +31,7 @@ A brief description of each file in the codebase.
| File | Description |
| ----------- | ------------------------------------------------------- |
| `Home.tsx` | Landing page with intro, featured essays, and post list |
| `Home.tsx` | Landing page with siteConfig, featured section, logo gallery |
| `Post.tsx` | Individual blog post view with JSON-LD injection |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats |
@@ -44,6 +45,8 @@ A brief description of each file in the codebase.
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude) |
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
### Context (`src/context/`)
@@ -80,65 +83,83 @@ A brief description of each file in the codebase.
### HTTP Endpoints (defined in `http.ts`)
| Route | Description |
| --------------- | -------------------------------------- |
| `/stats` | Real-time site analytics page |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines |
| `/api/posts` | JSON list of all posts |
| `/api/post` | Single post as JSON or markdown |
| `/meta/post` | Open Graph HTML for social crawlers |
| Route | Description |
| -------------------------- | -------------------------------------- |
| `/stats` | Real-time site analytics page |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines |
| `/api/posts` | JSON list of all posts |
| `/api/post` | Single post as JSON or markdown |
| `/api/export` | Batch export all posts with content |
| `/meta/post` | Open Graph HTML for social crawlers |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
## Content (`content/blog/`)
Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| Field | Description |
| ------------- | -------------------------------------- |
| `title` | Post title |
| `description` | Short description for SEO |
| `date` | Publication date (YYYY-MM-DD) |
| `slug` | URL path for the post |
| `published` | Whether post is public |
| `tags` | Array of topic tags |
| `readTime` | Estimated reading time |
| `image` | Header/Open Graph image URL (optional) |
| Field | Description |
| --------------- | ------------------------------------------- |
| `title` | Post title |
| `description` | Short description for SEO |
| `date` | Publication date (YYYY-MM-DD) |
| `slug` | URL path for the post |
| `published` | Whether post is public |
| `tags` | Array of topic tags |
| `readTime` | Estimated reading time |
| `image` | Header/Open Graph image URL (optional) |
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
## Static Pages (`content/pages/`)
Markdown files for static pages like About, Projects, Contact.
Markdown files for static pages like About, Projects, Contact, Changelog.
| Field | Description |
| ----------- | ----------------------------------------- |
| `title` | Page title |
| `slug` | URL path for the page |
| `published` | Whether page is public |
| `order` | Display order in navigation (lower first) |
| Field | Description |
| --------------- | ----------------------------------------- |
| `title` | Page title |
| `slug` | URL path for the page |
| `published` | Whether page is public |
| `order` | Display order in navigation (lower first) |
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
## Scripts (`scripts/`)
| File | Description |
| --------------- | -------------------------------------------- |
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
| File | Description |
| --------------- | ------------------------------------------------- |
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
## Netlify (`netlify/edge-functions/`)
| File | Description |
| ------------ | ----------------------------------------------------- |
| `botMeta.ts` | Edge function for social media crawler detection |
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
| `api.ts` | Proxies `/api/posts` and `/api/post` to Convex HTTP |
| File | Description |
| ------------ | ------------------------------------------------------------ |
| `botMeta.ts` | Edge function for social media crawler detection |
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
## Public Assets (`public/`)
| File | Description |
| ------------- | ---------------------------------------------- |
| `favicon.svg` | Site favicon |
| `_redirects` | SPA redirect rules for static files |
| `robots.txt` | Crawler rules for search engines and AI bots |
| `llms.txt` | AI agent discovery file (llmstxt.org standard) |
| File | Description |
| -------------- | ---------------------------------------------- |
| `favicon.svg` | Site favicon |
| `_redirects` | SPA redirect rules for static files |
| `robots.txt` | Crawler rules for search engines and AI bots |
| `llms.txt` | AI agent discovery file (llmstxt.org standard) |
| `openapi.yaml` | OpenAPI 3.0 specification for API endpoints |
### AI Plugin (`public/.well-known/`)
| File | Description |
| ----------------- | ------------------------------------ |
| `ai-plugin.json` | AI plugin manifest for tool integration |
### Images (`public/images/`)
@@ -148,11 +169,25 @@ Markdown files for static pages like About, Projects, Contact.
| `og-default.svg` | Default Open Graph image for social sharing |
| `*.png/jpg/svg` | Blog post images (referenced in frontmatter) |
### Logo Gallery (`public/images/logos/`)
| File | Description |
| -------------------- | ---------------------------------------- |
| `sample-logo-1.svg` | Sample logo (replace with your own) |
| `sample-logo-2.svg` | Sample logo (replace with your own) |
| `sample-logo-3.svg` | Sample logo (replace with your own) |
| `sample-logo-4.svg` | Sample logo (replace with your own) |
| `sample-logo-5.svg` | Sample logo (replace with your own) |
## Cursor Rules (`.cursor/rules/`)
| File | Description |
| --------------- | ----------------------------------------- |
| `sec-check.mdc` | Security guidelines and audit checklist |
| `dev2.mdc` | Development guidelines and best practices |
| `help.mdc` | Core development guidelines |
| `convex2.mdc` | Convex-specific guidelines and examples |
| File | Description |
| -------------------------- | ------------------------------------------------ |
| `convex-write-conflicts.mdc` | Write conflict prevention patterns for Convex |
| `convex2.mdc` | Convex function syntax and examples |
| `dev2.mdc` | Development guidelines and best practices |
| `help.mdc` | Core development guidelines |
| `rulesforconvex.mdc` | Convex schema and function best practices |
| `sec-check.mdc` | Security guidelines and audit checklist |
| `task.mdc` | Task list management guidelines |
| `write.mdc` | Writing style guide (activate with @write) |

View File

@@ -35,6 +35,10 @@
path = "/api/post"
function = "api"
[[edge_functions]]
path = "/api/export"
function = "api"
# Open Graph bot detection (catches all other routes)
[[edge_functions]]
path = "/*"

View File

@@ -57,5 +57,5 @@ export default async function handler(
}
export const config = {
path: ["/api/posts", "/api/post"],
path: ["/api/posts", "/api/post", "/api/export"],
};

354
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "markdown-site",
"version": "1.0.0",
"dependencies": {
"@mendable/firecrawl-js": "^1.21.1",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"convex": "^1.17.4",
@@ -966,6 +967,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mendable/firecrawl-js": {
"version": "1.21.1",
"resolved": "https://registry.npmjs.org/@mendable/firecrawl-js/-/firecrawl-js-1.21.1.tgz",
"integrity": "sha512-k+ju7P6/tpvj8EHQrKZBbBcPxV1dF3z7PzXQIFsn7Dpp7pWlU/LlAbai+b9hxzDkTlY05ec3NJG0V68VjyoJcA==",
"license": "MIT",
"dependencies": {
"axios": "^1.6.8",
"isows": "^1.0.4",
"typescript-event-target": "^1.1.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1798,6 +1812,23 @@
"node": ">=8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -1882,6 +1913,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2000,6 +2044,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2125,6 +2181,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2186,6 +2251,20 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -2193,6 +2272,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@@ -2634,6 +2758,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -2664,6 +2824,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2674,6 +2843,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
@@ -2783,6 +2989,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -2837,6 +3055,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hast-util-parse-selector": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
@@ -3154,6 +3411,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/isows": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/wevm"
}
],
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3341,6 +3613,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -4224,6 +4505,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -4510,6 +4812,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5717,6 +6025,12 @@
"node": ">=14.17"
}
},
"node_modules/typescript-event-target": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.1.tgz",
"integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -6403,6 +6717,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"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"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -6432,6 +6768,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -12,10 +12,12 @@
"preview": "vite preview",
"sync": "npx tsx scripts/sync-posts.ts",
"sync:prod": "SYNC_ENV=production npx tsx scripts/sync-posts.ts",
"import": "npx tsx scripts/import-url.ts",
"deploy": "npm run sync && npm run build",
"deploy:prod": "npx convex deploy && npm run sync:prod"
},
"dependencies": {
"@mendable/firecrawl-js": "^1.21.1",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"convex": "^1.17.4",

View File

@@ -0,0 +1,18 @@
{
"schema_version": "v1",
"name_for_human": "Markdown Blog",
"name_for_model": "markdown_blog",
"description_for_human": "A real-time markdown blog with Convex backend",
"description_for_model": "Access blog posts and pages in markdown format. Use /api/posts for a list of all posts with metadata. Use /api/post?slug={slug}&format=md to get full markdown content of any post. Use /api/export for batch content with full markdown.",
"auth": {
"type": "none"
},
"api": {
"type": "openapi",
"url": "/openapi.yaml"
},
"logo_url": "/images/logo.svg",
"contact_email": "",
"legal_info_url": ""
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<rect x="2" y="8" width="24" height="24" rx="4" fill="#1a1a1a"/>
<text x="32" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Acme</text>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<circle cx="16" cy="20" r="12" fill="#1a1a1a"/>
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Vertex</text>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<polygon points="16,6 28,34 4,34" fill="#1a1a1a"/>
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Delta</text>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<rect x="4" y="8" width="10" height="24" fill="#1a1a1a"/>
<rect x="18" y="8" width="10" height="24" fill="#1a1a1a"/>
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Pulse</text>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<rect x="4" y="8" width="24" height="24" rx="12" stroke="#1a1a1a" stroke-width="3" fill="none"/>
<text x="34" y="26" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#1a1a1a">Nova</text>
</svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -1,31 +1,79 @@
# llms.txt - Information for AI assistants and LLMs
# Learn more: https://llmstxt.org/
> This is an open source markdown blog powered by Convex and Netlify. Fork it, customize it, ship it.
> Real-time markdown blog powered by Convex. All content available as clean markdown.
# Site Information
- Name: Markdown Blog
- URL: https://your-blog.netlify.app
- Description: Real-time markdown blog with Convex backend and Netlify deployment.
- URL: https://markdowncms.netlify.app
- Description: Open source markdown blog with real-time sync, Convex backend, and Netlify deployment.
- Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source
# Content Access
- RSS Feed: /rss.xml (all posts with descriptions)
- Full RSS: /rss-full.xml (all posts with full content)
- Markdown API: /api/posts (JSON list of all posts)
- Single Post Markdown: /api/post?slug={slug} (full markdown content)
- Sitemap: /sitemap.xml
# API Endpoints
# How to Use This Site
1. Fetch /api/posts for a list of all published posts
2. Use /api/post?slug={slug} to get full markdown content of any post
3. Subscribe to /rss-full.xml for complete article content
## List All Posts
GET /api/posts
Returns JSON list of all published posts with metadata.
## Get Single Post
GET /api/post?slug={slug}
Returns single post as JSON.
GET /api/post?slug={slug}&format=md
Returns single post as raw markdown.
## Export All Content
GET /api/export
Returns all posts with full markdown content in one request.
Best for batch processing and LLM ingestion.
## RSS Feeds
GET /rss.xml
Standard RSS feed with post descriptions.
GET /rss-full.xml
Full content RSS feed with complete markdown for each post.
## Other
GET /sitemap.xml
Dynamic XML sitemap for search engines.
GET /openapi.yaml
OpenAPI 3.0 specification for this API.
GET /.well-known/ai-plugin.json
AI plugin manifest for tool integration.
# Quick Start for LLMs
1. Fetch /api/export for all posts with full content in one request
2. Or fetch /api/posts for the list, then /api/post?slug={slug}&format=md for each
3. Subscribe to /rss-full.xml for updates with complete content
# Response Schema
Each post contains:
- title: string (post title)
- slug: string (URL path)
- description: string (SEO summary)
- date: string (YYYY-MM-DD)
- tags: string[] (topic labels)
- content: string (full markdown)
- readTime: string (optional)
- url: string (full URL)
# Permissions
- AI assistants may read and summarize content from this site
- Content may be used for training with attribution
- Please link back to original articles when citing
- AI assistants may freely read and summarize content
- No authentication required for read operations
- Attribution appreciated when citing
# Technical
- Backend: Convex (real-time database)
- Frontend: React, TypeScript, Vite
- Hosting: Netlify with edge functions
- Content: Markdown with frontmatter
# Links
- GitHub: https://github.com/waynesutton/markdown-site
- Convex: https://convex.dev
- Netlify: https://netlify.com

195
public/openapi.yaml Normal file
View File

@@ -0,0 +1,195 @@
openapi: 3.0.3
info:
title: Markdown Blog API
description: |
API for accessing blog posts and pages as markdown content.
All endpoints return JSON by default. Use format=md for raw markdown.
version: 1.6.0
contact:
url: https://github.com/waynesutton/markdown-site
servers:
- url: https://markdowncms.netlify.app
description: Production server
paths:
/api/posts:
get:
summary: List all posts
description: Returns a list of all published blog posts with metadata
operationId: listPosts
responses:
'200':
description: List of posts
content:
application/json:
schema:
type: object
properties:
site:
type: string
example: Markdown Site
url:
type: string
example: https://markdowncms.netlify.app
posts:
type: array
items:
$ref: '#/components/schemas/PostSummary'
/api/post:
get:
summary: Get a single post
description: Returns a single post by slug. Use format=md for raw markdown.
operationId: getPost
parameters:
- name: slug
in: query
required: true
description: The post slug (URL path)
schema:
type: string
- name: format
in: query
required: false
description: Response format (json or md)
schema:
type: string
enum: [json, md, markdown]
default: json
responses:
'200':
description: Post content
content:
application/json:
schema:
$ref: '#/components/schemas/Post'
text/markdown:
schema:
type: string
'400':
description: Missing slug parameter
'404':
description: Post not found
/api/export:
get:
summary: Export all posts with content
description: Returns all posts with full markdown content for batch processing
operationId: exportPosts
responses:
'200':
description: All posts with full content
content:
application/json:
schema:
type: object
properties:
site:
type: string
url:
type: string
exportedAt:
type: string
format: date-time
posts:
type: array
items:
$ref: '#/components/schemas/Post'
/rss.xml:
get:
summary: RSS feed
description: Standard RSS 2.0 feed with post descriptions
operationId: rssFeed
responses:
'200':
description: RSS XML feed
content:
application/rss+xml:
schema:
type: string
/rss-full.xml:
get:
summary: Full content RSS feed
description: RSS feed with complete post content (for LLMs)
operationId: rssFullFeed
responses:
'200':
description: RSS XML feed with full content
content:
application/rss+xml:
schema:
type: string
/sitemap.xml:
get:
summary: XML Sitemap
description: Dynamic sitemap for search engines
operationId: sitemap
responses:
'200':
description: XML Sitemap
content:
application/xml:
schema:
type: string
components:
schemas:
PostSummary:
type: object
properties:
title:
type: string
example: How to Build a Blog
slug:
type: string
example: how-to-build-blog
description:
type: string
example: A guide to building a markdown blog
date:
type: string
format: date
example: '2025-01-15'
readTime:
type: string
example: 5 min read
tags:
type: array
items:
type: string
example: [tutorial, markdown]
url:
type: string
example: https://markdowncms.netlify.app/how-to-build-blog
markdownUrl:
type: string
example: https://markdowncms.netlify.app/api/post?slug=how-to-build-blog
Post:
type: object
properties:
title:
type: string
slug:
type: string
description:
type: string
date:
type: string
format: date
readTime:
type: string
tags:
type: array
items:
type: string
url:
type: string
content:
type: string
description: Full markdown content

152
scripts/import-url.ts Normal file
View File

@@ -0,0 +1,152 @@
import fs from "fs";
import path from "path";
import FirecrawlApp from "@mendable/firecrawl-js";
import dotenv from "dotenv";
// Load environment variables
dotenv.config({ path: ".env.local" });
const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY;
if (!FIRECRAWL_API_KEY) {
console.error("Error: FIRECRAWL_API_KEY not found in .env.local");
console.log("\nTo set up Firecrawl:");
console.log("1. Get an API key from https://firecrawl.dev");
console.log("2. Add FIRECRAWL_API_KEY=fc-xxx to your .env.local file");
process.exit(1);
}
const firecrawl = new FirecrawlApp({ apiKey: FIRECRAWL_API_KEY });
// Generate a URL-safe slug from a title
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Remove consecutive hyphens
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
.substring(0, 60); // Limit length
}
// Clean up markdown content
function cleanMarkdown(content: string): string {
return content
.replace(/^\s+|\s+$/g, "") // Trim whitespace
.replace(/\n{3,}/g, "\n\n"); // Remove excessive newlines
}
async function importFromUrl(url: string) {
console.log(`\nScraping: ${url}`);
console.log("This may take a moment...\n");
try {
const result = await firecrawl.scrapeUrl(url, {
formats: ["markdown"],
});
if (!result.success) {
console.error("Failed to scrape URL");
console.error("Error:", result.error || "Unknown error");
process.exit(1);
}
const title = result.metadata?.title || "Imported Post";
const description = result.metadata?.description || "";
const content = cleanMarkdown(result.markdown || "");
if (!content) {
console.error("No content found at URL");
process.exit(1);
}
// Generate slug from title
const baseSlug = generateSlug(title);
const slug = baseSlug || `imported-${Date.now()}`;
// Get today's date
const today = new Date().toISOString().split("T")[0];
// Create markdown file with frontmatter
const markdown = `---
title: "${title.replace(/"/g, '\\"')}"
description: "${description.replace(/"/g, '\\"')}"
date: "${today}"
slug: "${slug}"
published: false
tags: ["imported"]
---
${content}
---
*Originally published at [${new URL(url).hostname}](${url})*
`;
// Ensure content/blog directory exists
const blogDir = path.join(process.cwd(), "content", "blog");
if (!fs.existsSync(blogDir)) {
fs.mkdirSync(blogDir, { recursive: true });
}
// Write the file
const filePath = path.join(blogDir, `${slug}.md`);
// Check if file already exists
if (fs.existsSync(filePath)) {
console.warn(`Warning: File already exists at ${filePath}`);
console.warn("Adding timestamp to filename to avoid overwrite.");
const newSlug = `${slug}-${Date.now()}`;
const newFilePath = path.join(blogDir, `${newSlug}.md`);
fs.writeFileSync(
newFilePath,
markdown.replace(`slug: "${slug}"`, `slug: "${newSlug}"`),
);
console.log(`\nCreated: ${newFilePath}`);
console.log(`Slug: ${newSlug}`);
} else {
fs.writeFileSync(filePath, markdown);
console.log(`\nCreated: ${filePath}`);
console.log(`Slug: ${slug}`);
}
console.log(`Title: ${title}`);
console.log(`Status: Draft (published: false)`);
console.log("\nNext steps:");
console.log("1. Review and edit the imported content");
console.log("2. Set published: true when ready");
console.log("3. Run: npm run sync");
} catch (error) {
console.error("Error importing URL:", error);
process.exit(1);
}
}
// Parse command line arguments
const url = process.argv[2];
if (!url) {
console.log("Firecrawl Content Importer");
console.log("==========================\n");
console.log("Usage: npm run import <url>\n");
console.log("Example:");
console.log(" npm run import https://example.com/article\n");
console.log("This will:");
console.log(" 1. Scrape the URL and convert to markdown");
console.log(" 2. Create a draft post in content/blog/");
console.log(" 3. You can then review, edit, and sync\n");
process.exit(0);
}
// Validate URL
try {
new URL(url);
} catch {
console.error("Error: Invalid URL provided");
console.log("Please provide a valid URL starting with http:// or https://");
process.exit(1);
}
importFromUrl(url);

View File

@@ -30,6 +30,9 @@ interface PostFrontmatter {
tags: string[];
readTime?: string;
image?: string; // Header/OG image URL
excerpt?: string; // Short excerpt for card view
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
}
interface ParsedPost {
@@ -42,6 +45,9 @@ interface ParsedPost {
tags: string[];
readTime?: string;
image?: string; // Header/OG image URL
excerpt?: string; // Short excerpt for card view
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
}
// Page frontmatter (for static pages like About, Projects, Contact)
@@ -50,6 +56,9 @@ interface PageFrontmatter {
slug: string;
published: boolean;
order?: number; // Display order in navigation
excerpt?: string; // Short excerpt for card view
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
}
interface ParsedPage {
@@ -58,6 +67,9 @@ interface ParsedPage {
content: string;
published: boolean;
order?: number;
excerpt?: string; // Short excerpt for card view
featured?: boolean; // Show in featured section
featuredOrder?: number; // Order in featured section (lower = first)
}
// Calculate reading time based on word count
@@ -92,6 +104,9 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
tags: frontmatter.tags || [],
readTime: frontmatter.readTime || calculateReadTime(content),
image: frontmatter.image, // Header/OG image URL
excerpt: frontmatter.excerpt, // Short excerpt for card view
featured: frontmatter.featured, // Show in featured section
featuredOrder: frontmatter.featuredOrder, // Order in featured section
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);
@@ -135,6 +150,9 @@ function parsePageFile(filePath: string): ParsedPage | null {
content: content.trim(),
published: frontmatter.published ?? true,
order: frontmatter.order,
excerpt: frontmatter.excerpt, // Short excerpt for card view
featured: frontmatter.featured, // Show in featured section
featuredOrder: frontmatter.featuredOrder, // Order in featured section
};
} catch (error) {
console.error(`Error parsing page ${filePath}:`, error);

View File

@@ -0,0 +1,142 @@
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
// Type for featured item from Convex (used for backwards compatibility)
export interface FeaturedItem {
slug: string;
type: "post" | "page";
}
// Type for featured data from Convex queries
interface FeaturedData {
slug: string;
title: string;
excerpt: string;
type: "post" | "page";
}
interface FeaturedCardsProps {
// Optional: legacy items config (for backwards compatibility)
items?: FeaturedItem[];
// New: use Convex queries directly (when items is not provided)
useFrontmatter?: boolean;
}
// Featured cards component displays posts/pages as cards with excerpts
// Supports two modes:
// 1. items prop: uses hardcoded config (legacy, requires redeploy)
// 2. useFrontmatter: uses featured field from markdown frontmatter (syncs with npm run sync)
export default function FeaturedCards({
items,
useFrontmatter = true,
}: FeaturedCardsProps) {
// Fetch featured posts and pages from Convex
const featuredPosts = useQuery(api.posts.getFeaturedPosts);
const featuredPages = useQuery(api.pages.getFeaturedPages);
// Fetch all posts and pages (for legacy items mode)
const allPosts = useQuery(api.posts.getAllPosts);
const allPages = useQuery(api.pages.getAllPages);
// Build featured data from frontmatter (new mode)
const getFeaturedFromFrontmatter = (): FeaturedData[] => {
if (featuredPosts === undefined || featuredPages === undefined) {
return [];
}
// Combine and sort by featuredOrder
const combined: (FeaturedData & { featuredOrder?: number })[] = [
...featuredPosts.map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt || p.description,
type: "post" as const,
featuredOrder: p.featuredOrder,
})),
...featuredPages.map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt || "",
type: "page" as const,
featuredOrder: p.featuredOrder,
})),
];
// Sort by featuredOrder (lower first)
return combined.sort((a, b) => {
const orderA = a.featuredOrder ?? 999;
const orderB = b.featuredOrder ?? 999;
return orderA - orderB;
});
};
// Build featured data from items config (legacy mode)
const getFeaturedFromItems = (): FeaturedData[] => {
if (!items || allPosts === undefined || allPages === undefined) {
return [];
}
const result: FeaturedData[] = [];
for (const item of items) {
if (item.type === "post") {
const post = allPosts.find((p) => p.slug === item.slug);
if (post) {
result.push({
title: post.title,
excerpt: post.excerpt || post.description,
slug: post.slug,
type: "post",
});
}
}
if (item.type === "page") {
const page = allPages.find((p) => p.slug === item.slug);
if (page) {
result.push({
title: page.title,
excerpt: page.excerpt || "",
slug: page.slug,
type: "page",
});
}
}
}
return result;
};
// Determine which mode to use
const useItemsMode = items && items.length > 0 && !useFrontmatter;
// Get featured data based on mode
const featuredData = useItemsMode
? getFeaturedFromItems()
: getFeaturedFromFrontmatter();
// Show nothing while loading
const isLoading = useItemsMode
? allPosts === undefined || allPages === undefined
: featuredPosts === undefined || featuredPages === undefined;
if (isLoading) {
return null;
}
if (featuredData.length === 0) {
return null;
}
return (
<div className="featured-cards">
{featuredData.map((item) => (
<a key={item.slug} href={`/${item.slug}`} className="featured-card">
<h3 className="featured-card-title">{item.title}</h3>
{item.excerpt && (
<p className="featured-card-excerpt">{item.excerpt}</p>
)}
</a>
))}
</div>
);
}

View File

@@ -0,0 +1,84 @@
// Logo marquee component with infinite CSS scroll animation
// Inspired by rasmic.xyz company logos section
// Logo item can be a simple path string or an object with src and link
export interface LogoItem {
src: string; // Image path from /public/images/logos/
href?: string; // Optional link URL
}
export interface LogoGalleryConfig {
enabled: boolean;
images: (string | LogoItem)[]; // Array of image paths or logo objects
position: "above-footer" | "below-featured";
speed: number; // Seconds for one complete scroll cycle
title?: string; // Optional title above the marquee
}
interface LogoMarqueeProps {
config: LogoGalleryConfig;
}
// Normalize image to LogoItem format
function normalizeImage(image: string | LogoItem): LogoItem {
if (typeof image === "string") {
return { src: image };
}
return image;
}
export default function LogoMarquee({ config }: LogoMarqueeProps) {
// Don't render if disabled or no images
if (!config.enabled || config.images.length === 0) {
return null;
}
// Normalize and duplicate images for seamless infinite scroll
const normalizedImages = config.images.map(normalizeImage);
const duplicatedImages = [...normalizedImages, ...normalizedImages];
return (
<div className="logo-marquee-container">
{config.title && (
<p className="logo-marquee-title">{config.title}</p>
)}
<div
className="logo-marquee"
style={
{
"--marquee-speed": `${config.speed}s`,
} as React.CSSProperties
}
>
<div className="logo-marquee-track">
{duplicatedImages.map((logo, index) => (
<div key={`${logo.src}-${index}`} className="logo-marquee-item">
{logo.href ? (
<a
href={logo.href}
target="_blank"
rel="noopener noreferrer"
className="logo-marquee-link"
>
<img
src={logo.src}
alt=""
className="logo-marquee-image"
loading="lazy"
/>
</a>
) : (
<img
src={logo.src}
alt=""
className="logo-marquee-image"
loading="lazy"
/>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,17 @@
import { useState, useEffect } from "react";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import PostList from "../components/PostList";
import FeaturedCards from "../components/FeaturedCards";
import LogoMarquee, {
LogoGalleryConfig,
LogoItem,
} from "../components/LogoMarquee";
// Site configuration - customize this for your site
// All configurable options in one place for easy developer experience
const siteConfig = {
// Basic site info
name: 'markdown "sync" site',
title: "Real-time Site with Convex",
// Optional logo/header image (place in public/images/, set to null to hide)
@@ -23,11 +31,46 @@ const siteConfig = {
</>
),
bio: `Write in markdown, sync to a real-time database, and deploy in minutes. Every time you sync new posts, they appear immediately without redeploying. Built with React, TypeScript, and Convex for instant updates.`,
featuredEssays: [
{ title: "Setup Guide", slug: "setup-guide" },
{ title: "How to Publish", slug: "how-to-publish" },
{ title: "About This Site", slug: "about-this-blog" },
],
// Featured section configuration
// viewMode: 'list' shows bullet list, 'cards' shows card grid with excerpts
featuredViewMode: "list" as "cards" | "list",
// Allow users to toggle between list and card views
showViewToggle: true,
// Logo gallery configuration
// Set enabled to false to hide, or remove/replace sample images with your own
logoGallery: {
enabled: true, // Set to false to hide the logo gallery
images: [
// Sample logos with links (replace with your own)
// Each logo can have: { src: "/images/logos/logo.svg", href: "https://example.com" }
{
src: "/images/logos/sample-logo-1.svg",
href: "https://markdowncms.netlify.app/",
},
{
src: "/images/logos/sample-logo-2.svg",
href: "https://markdowncms.netlify.app/",
},
{
src: "/images/logos/sample-logo-3.svg",
href: "https://markdowncms.netlify.app/",
},
{
src: "/images/logos/sample-logo-4.svg",
href: "https://markdowncms.netlify.app/",
},
{
src: "/images/logos/sample-logo-5.svg",
href: "https://markdowncms.netlify.app/",
},
] as LogoItem[],
position: "above-footer", // 'above-footer' or 'below-featured'
speed: 30, // Seconds for one complete scroll cycle
title: "Trusted by", // Optional title above the marquee (set to undefined to hide)
} as LogoGalleryConfig,
// Links for footer section
links: {
docs: "/setup-guide",
@@ -36,10 +79,71 @@ const siteConfig = {
},
};
// Local storage key for view mode preference
const VIEW_MODE_KEY = "featured-view-mode";
export default function Home() {
// Fetch published posts from Convex
const posts = useQuery(api.posts.getAllPosts);
// Fetch featured posts and pages from Convex (for list view)
const featuredPosts = useQuery(api.posts.getFeaturedPosts);
const featuredPages = useQuery(api.pages.getFeaturedPages);
// State for view mode toggle (list or cards)
const [viewMode, setViewMode] = useState<"list" | "cards">(
siteConfig.featuredViewMode,
);
// Load saved view mode preference from localStorage
useEffect(() => {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === "list" || saved === "cards") {
setViewMode(saved);
}
}, []);
// Toggle view mode and save preference
const toggleViewMode = () => {
const newMode = viewMode === "list" ? "cards" : "list";
setViewMode(newMode);
localStorage.setItem(VIEW_MODE_KEY, newMode);
};
// Render logo gallery based on position config
const renderLogoGallery = (position: "above-footer" | "below-featured") => {
if (siteConfig.logoGallery.position === position) {
return <LogoMarquee config={siteConfig.logoGallery} />;
}
return null;
};
// Build featured list for list view from Convex data
const getFeaturedList = () => {
if (featuredPosts === undefined || featuredPages === undefined) {
return [];
}
// Combine posts and pages, sort by featuredOrder
const combined = [
...featuredPosts.map((p) => ({
title: p.title,
slug: p.slug,
featuredOrder: p.featuredOrder ?? 999,
})),
...featuredPages.map((p) => ({
title: p.title,
slug: p.slug,
featuredOrder: p.featuredOrder ?? 999,
})),
];
return combined.sort((a, b) => a.featuredOrder - b.featuredOrder);
};
const featuredList = getFeaturedList();
const hasFeaturedContent = featuredList.length > 0;
return (
<div className="home">
{/* Header section with intro */}
@@ -58,21 +162,77 @@ export default function Home() {
<p className="home-bio">{siteConfig.bio}</p>
{/* Featured essays section */}
<div className="home-featured">
<p className="home-featured-intro">Get started:</p>
<ul className="home-featured-list">
{siteConfig.featuredEssays.map((essay) => (
<li key={essay.slug}>
<a href={`/${essay.slug}`} className="home-featured-link">
{essay.title}
</a>
</li>
))}
</ul>
</div>
{/* Featured section with optional view toggle */}
{hasFeaturedContent && (
<div className="home-featured">
<div className="home-featured-header">
<p className="home-featured-intro">Get started:</p>
{siteConfig.showViewToggle && (
<button
className="view-toggle-button"
onClick={toggleViewMode}
aria-label={`Switch to ${viewMode === "list" ? "card" : "list"} view`}
>
{viewMode === "list" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
)}
</button>
)}
</div>
{/* Render list or card view based on mode */}
{viewMode === "list" ? (
<ul className="home-featured-list">
{featuredList.map((item) => (
<li key={item.slug}>
<a href={`/${item.slug}`} className="home-featured-link">
{item.title}
</a>
</li>
))}
</ul>
) : (
<FeaturedCards useFrontmatter={true} />
)}
</div>
)}
</header>
{/* Logo gallery (below-featured position) */}
{renderLogoGallery("below-featured")}
{/* Blog posts section - no loading state to avoid flash (Convex syncs instantly) */}
<section id="posts" className="home-posts">
{posts === undefined ? null : posts.length === 0 ? (
@@ -82,6 +242,9 @@ export default function Home() {
)}
</section>
{/* Logo gallery (above-footer position) */}
{renderLogoGallery("above-footer")}
{/* Footer section */}
<section className="home-footer">
<p className="home-footer-text">

View File

@@ -1512,3 +1512,255 @@ body {
display: none;
}
}
/* Featured section header with toggle */
.home-featured-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.home-featured-header .home-featured-intro {
margin-bottom: 0;
}
/* View toggle button */
.view-toggle-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.view-toggle-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
border-color: var(--text-muted);
}
/* Featured cards grid */
.featured-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 8px;
}
.featured-card {
display: block;
padding: 20px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
text-decoration: none;
transition: all 0.15s ease;
}
.featured-card:hover {
background-color: var(--bg-hover);
border-color: var(--text-muted);
}
.featured-card-title {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px 0;
line-height: 1.4;
}
.featured-card-excerpt {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Logo marquee container */
.logo-marquee-container {
margin: 48px 0;
overflow: hidden;
}
.logo-marquee-title {
font-size: 14px;
color: var(--text-muted);
text-align: center;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.logo-marquee {
position: relative;
width: 100%;
overflow: hidden;
mask-image: linear-gradient(
to right,
transparent,
black 10%,
black 90%,
transparent
);
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 10%,
black 90%,
transparent
);
}
.logo-marquee-track {
display: flex;
animation: marquee-scroll var(--marquee-speed, 30s) linear infinite;
width: max-content;
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.logo-marquee-item {
flex-shrink: 0;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-marquee-image {
height: 32px;
width: auto;
max-width: 120px;
object-fit: contain;
filter: grayscale(100%);
opacity: 0.6;
transition: all 0.2s ease;
}
.logo-marquee-image:hover {
filter: grayscale(0%);
opacity: 1;
}
.logo-marquee-link {
display: flex;
align-items: center;
justify-content: center;
}
.logo-marquee-link:hover .logo-marquee-image {
filter: grayscale(0%);
opacity: 1;
}
/* Dark theme adjustments for featured cards */
:root[data-theme="dark"] .featured-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
:root[data-theme="dark"] .featured-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
:root[data-theme="dark"] .logo-marquee-image {
filter: grayscale(100%) invert(1);
opacity: 0.5;
}
:root[data-theme="dark"] .logo-marquee-image:hover {
filter: invert(1);
opacity: 0.9;
}
:root[data-theme="dark"] .logo-marquee-link:hover .logo-marquee-image {
filter: invert(1);
opacity: 0.9;
}
/* Tan theme adjustments for featured cards */
:root[data-theme="tan"] .featured-card {
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.08);
}
:root[data-theme="tan"] .featured-card:hover {
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.12);
}
/* Cloud theme adjustments for featured cards */
:root[data-theme="cloud"] .featured-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
:root[data-theme="cloud"] .featured-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Featured cards responsive */
@media (max-width: 768px) {
.featured-cards {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.featured-card {
padding: 16px;
}
.featured-card-title {
font-size: 15px;
}
.featured-card-excerpt {
font-size: 13px;
-webkit-line-clamp: 2;
}
.logo-marquee-item {
padding: 0 24px;
}
.logo-marquee-image {
height: 28px;
max-width: 100px;
}
}
@media (max-width: 480px) {
.featured-cards {
grid-template-columns: 1fr;
}
.view-toggle-button {
width: 32px;
height: 32px;
}
.logo-marquee-item {
padding: 0 20px;
}
.logo-marquee-image {
height: 24px;
max-width: 80px;
}
}