docs: update blog post and TASK.md with v1.9.0 scroll-to-top and v1.10.0 fork configuration
Updated:
- content/blog/raw-markdown-and-copy-improvements.md
- Changed title from 'v1.7 and v1.8' to 'v1.7 to v1.10'
- Added Fork configuration section (v1.10.0) with 9-file table
- Added Scroll-to-top section (v1.9.0) with configuration options
- Updated summary to include all features from v1.7 to v1.10
- Fixed image path to /images/v17.png
- Updated sync command guidance for dev vs prod
- TASK.md
- Added new To Do items for future features
- Removed duplicate Future Enhancements section
- content/pages/docs.md
- Added Mobile menu section
- Added Copy Page dropdown table with all options
- Added Markdown tables section
- content/pages/about.md
- Updated Features list with new v1.8.0 features
- content/blog/setup-guide.md
- Added image field to pages schema
- Updated Project structure with new directories
- Added /raw/{slug}.md to API endpoints
- Added Mobile Navigation and Copy Page Dropdown sections
- Added featured image documentation with ordering details
Documentation now covers all features from v1.7.0 through v1.10.0.
55
README.md
@@ -1,9 +1,21 @@
|
||||
# markdown "sync" site
|
||||
|
||||
A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO, AI agents, and LLM discovery.
|
||||
An open-source markdown "sync" site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build powered by Convex and Netlify
|
||||
|
||||
Write markdown locally, run `npm run sync` (dev) or `npm run sync:prod` (production), and content appears instantly across all connected browsers. Built with React, Convex, and Vite. Optimized for SEO, AI agents, and LLM discovery.
|
||||
|
||||
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so all connected browsers update automatically.
|
||||
|
||||
**How versioning works:** Markdown files live in `content/blog/` and `content/pages/`. These are regular files in your git repo. Commit changes, review diffs, roll back like any codebase. The sync command pushes content to Convex.
|
||||
|
||||
```bash
|
||||
# Edit, commit, sync
|
||||
git add content/blog/my-post.md
|
||||
git commit -m "Update post"
|
||||
npm run sync # dev
|
||||
npm run sync:prod # production
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Markdown-based blog posts with frontmatter
|
||||
@@ -15,6 +27,7 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
|
||||
- Full text search with Command+K shortcut
|
||||
- Featured section with list/card view toggle
|
||||
- Logo gallery with continuous marquee scroll
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
|
||||
### SEO and Discovery
|
||||
|
||||
@@ -30,10 +43,11 @@ 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
|
||||
- `/raw/{slug}.md` - Static raw markdown files for each post and page
|
||||
- `/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
|
||||
- Copy Page dropdown for sharing to ChatGPT, Claude, Perplexity
|
||||
|
||||
### Content Import
|
||||
|
||||
@@ -41,6 +55,24 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
|
||||
- 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`
|
||||
|
||||
## Files to Update When Forking
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
| ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site URL and description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/.well-known/ai-plugin.json` | Site name and description |
|
||||
| `public/openapi.yaml` | API title and site name |
|
||||
|
||||
See the [Setup Guide](/setup-guide) for detailed configuration examples.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -156,22 +188,24 @@ Add these fields to any post or page frontmatter:
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "A short description for the card view."
|
||||
image: "/images/thumbnail.png"
|
||||
```
|
||||
|
||||
Then run `npm run sync`. No redeploy needed.
|
||||
Then run `npm run sync` (dev) or `npm run sync:prod` (production). 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 |
|
||||
| `image` | Thumbnail for card view (displays square) |
|
||||
|
||||
### 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
|
||||
- **Card view**: Grid of cards with thumbnail, title, and excerpt
|
||||
|
||||
Users can toggle between views. To change the default:
|
||||
|
||||
@@ -182,6 +216,12 @@ const siteConfig = {
|
||||
};
|
||||
```
|
||||
|
||||
### Thumbnail Images
|
||||
|
||||
In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to center. The list view shows links only (no images).
|
||||
|
||||
Square thumbnails: 400x400px minimum (800x800px for retina). The same image can serve as both the OG image for social sharing and the featured card thumbnail.
|
||||
|
||||
## Logo Gallery
|
||||
|
||||
The homepage includes a scrolling logo gallery with sample logos. Configure in `siteConfig`:
|
||||
@@ -214,6 +254,7 @@ logoGallery: {
|
||||
```
|
||||
|
||||
Each logo object supports:
|
||||
|
||||
- `src`: Path to the logo image (required)
|
||||
- `href`: URL to link to when clicked (optional)
|
||||
|
||||
@@ -336,7 +377,7 @@ 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 |
|
||||
@@ -394,7 +435,7 @@ 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 |
|
||||
|
||||
27
TASK.md
@@ -1,11 +1,29 @@
|
||||
# Markdown Blog - Tasks
|
||||
|
||||
## To Do
|
||||
|
||||
- [ ] Add blog page list and config
|
||||
- [ ] add github code block
|
||||
- [ ] Add markdown write page with copy option
|
||||
- [ ] create a ui site config page
|
||||
- [ ] Add app background image option
|
||||
|
||||
## Current Status
|
||||
|
||||
v1.6.1 ready for deployment. Build passes. TypeScript verified.
|
||||
v1.10.0 ready for deployment. Build passes. TypeScript verified. Documentation updated.
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Fork configuration documentation in docs.md and setup-guide.md
|
||||
- [x] "Files to Update When Forking" section with all 9 configuration files
|
||||
- [x] Backend configuration examples for Convex files
|
||||
- [x] Site branding updates across all AI discovery files
|
||||
- [x] Fork documentation added to README.md
|
||||
- [x] Blog post updated with v1.9.0 and v1.10.0 features
|
||||
- [x] Scroll-to-top button with configurable threshold
|
||||
- [x] Scroll-to-top documentation in docs.md and setup-guide.md
|
||||
- [x] Mobile menu with hamburger navigation for mobile and tablet
|
||||
- [x] Generate Skill feature in CopyPageDropdown
|
||||
- [x] Project setup with Vite + React + TypeScript
|
||||
- [x] Convex schema for posts, viewCounts, siteConfig, pages
|
||||
- [x] Build-time markdown sync script
|
||||
@@ -45,6 +63,11 @@ v1.6.1 ready for deployment. Build passes. TypeScript verified.
|
||||
- [x] AI plugin discovery at /.well-known/ai-plugin.json
|
||||
- [x] OpenAPI 3.0 spec at /openapi.yaml
|
||||
- [x] AGENTS.md for AI coding agents
|
||||
- [x] Static raw markdown files at /raw/{slug}.md
|
||||
- [x] View as Markdown option in CopyPageDropdown
|
||||
- [x] Perplexity added to AI service options
|
||||
- [x] Featured image support with square thumbnails in card view
|
||||
- [x] Improved markdown table CSS styling
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
@@ -53,7 +76,7 @@ v1.6.1 ready for deployment. Build passes. TypeScript verified.
|
||||
3. Connect repo to Netlify and deploy
|
||||
4. Edge functions automatically handle RSS, sitemap, and API routes
|
||||
|
||||
## Future Enhancements
|
||||
## Someday Features TBD
|
||||
|
||||
- [ ] Related posts suggestions
|
||||
- [ ] Newsletter signup
|
||||
|
||||
117
changelog.md
@@ -4,6 +4,123 @@ 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.10.0] - 2025-12-20
|
||||
|
||||
### Added
|
||||
|
||||
- Fork configuration documentation
|
||||
- "Files to Update When Forking" section in docs.md and setup-guide.md
|
||||
- Lists all 9 files with site-specific configuration
|
||||
- Backend configuration examples for Convex files
|
||||
- Code snippets for `convex/http.ts`, `convex/rss.ts`, `src/pages/Post.tsx`
|
||||
- Same documentation added to README.md for discoverability
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated site branding across all configuration files
|
||||
- `public/robots.txt`: Updated sitemap URL and header
|
||||
- `public/llms.txt`: Updated site name and description
|
||||
- `public/.well-known/ai-plugin.json`: Updated name and description for AI plugins
|
||||
- `public/openapi.yaml`: Updated API title and site name example
|
||||
- `convex/http.ts`: Updated SITE_URL and SITE_NAME constants
|
||||
|
||||
### Documentation
|
||||
|
||||
- Setup guide table of contents now includes fork configuration sections
|
||||
- Docs page configuration section expanded with backend file list
|
||||
- All AI discovery files reflect new "markdown sync site" branding
|
||||
|
||||
## [1.9.0] - 2025-12-20
|
||||
|
||||
### Added
|
||||
|
||||
- Scroll-to-top button
|
||||
- Appears after scrolling 300px (configurable)
|
||||
- Uses Phosphor ArrowUp icon for consistency
|
||||
- Smooth scroll animation (configurable)
|
||||
- Works with all four themes (dark, light, tan, cloud)
|
||||
- Enabled by default (can be disabled in Layout.tsx)
|
||||
- Fade-in animation when appearing
|
||||
- Responsive sizing for mobile devices
|
||||
|
||||
### Technical
|
||||
|
||||
- New component: `src/components/ScrollToTop.tsx`
|
||||
- Configurable via `ScrollToTopConfig` interface
|
||||
- Exports `defaultScrollToTopConfig` for customization
|
||||
- Uses passive scroll listener for performance
|
||||
- Configuration options in Layout.tsx `scrollToTopConfig`
|
||||
- CSS styles added to global.css with theme-specific shadows
|
||||
|
||||
## [1.8.0] - 2025-12-20
|
||||
|
||||
### Added
|
||||
|
||||
- Mobile menu with hamburger navigation
|
||||
- Slide-out drawer on mobile and tablet views
|
||||
- Accessible with keyboard navigation (Escape to close)
|
||||
- Focus trap for screen reader support
|
||||
- Smooth CSS transform animations
|
||||
- Page links and Home link in drawer
|
||||
- Auto-closes on route change
|
||||
- Generate Skill option in CopyPageDropdown
|
||||
- Formats post/page content as an AI agent skill file
|
||||
- Downloads as `{slug}-skill.md` with skill structure
|
||||
- Includes metadata, when to use, and instructions sections
|
||||
- Uses Download icon from lucide-react
|
||||
|
||||
### Changed
|
||||
|
||||
- Layout.tsx now includes hamburger button and MobileMenu component
|
||||
- Desktop navigation hidden on mobile, mobile menu hidden on desktop
|
||||
- Improved responsive navigation across all breakpoints
|
||||
|
||||
### Technical
|
||||
|
||||
- New component: `src/components/MobileMenu.tsx`
|
||||
- HamburgerButton exported from MobileMenu for Layout use
|
||||
- New `formatAsSkill()` function for skill file generation
|
||||
- New `handleDownloadSkill()` handler with blob download logic
|
||||
- Uses browser File API for client-side file download
|
||||
- CSS styles for mobile menu in global.css
|
||||
|
||||
## [1.7.0] - 2025-12-20
|
||||
|
||||
### Added
|
||||
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
- Generated during `npm run sync` (development) or `npm run sync:prod` (production) in `public/raw/` directory
|
||||
- Each published post and page gets a corresponding static `.md` file
|
||||
- SEO indexable and accessible to AI agents
|
||||
- Includes metadata header (type, date, reading time, tags)
|
||||
- View as Markdown option in CopyPageDropdown
|
||||
- Opens raw `.md` file in new tab
|
||||
- Available on all post and page views
|
||||
- Perplexity added to AI service options in CopyPageDropdown
|
||||
- Sends full markdown content via URL parameter
|
||||
- Research articles directly in Perplexity
|
||||
- Featured image support for posts and pages
|
||||
- `image` field in frontmatter displays as square thumbnail in card view
|
||||
- Non-square images automatically cropped to center
|
||||
- Recommended size: 400x400px minimum (800x800px for retina)
|
||||
|
||||
### Changed
|
||||
|
||||
- CopyPageDropdown now accepts `slug` prop for raw file links
|
||||
- Updated `_redirects` to serve `/raw/*` files directly
|
||||
- Improved markdown table CSS styling
|
||||
- GitHub-style tables with proper borders
|
||||
- Mobile responsive with horizontal scroll
|
||||
- Theme-aware alternating row colors
|
||||
- Hover states for better readability
|
||||
|
||||
### Technical
|
||||
|
||||
- Updated `scripts/sync-posts.ts` to generate `public/raw/` files
|
||||
- Files are regenerated on each sync (old files cleaned up)
|
||||
- Only published posts and pages generate raw files
|
||||
- CopyPageDropdown uses FileText icon from lucide-react for View as Markdown
|
||||
|
||||
## [1.6.1] - 2025-12-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,14 +6,14 @@ slug: "about-this-blog"
|
||||
published: true
|
||||
tags: ["convex", "netlify", "open-source", "markdown"]
|
||||
readTime: "4 min read"
|
||||
featured: true
|
||||
featured: false
|
||||
featuredOrder: 3
|
||||
excerpt: "Learn how this open source site works with real-time sync and instant updates."
|
||||
---
|
||||
|
||||
# About This Markdown Site
|
||||
|
||||
This is an open-source markdown site built with React, TypeScript, and Convex. Write posts and pages in markdown, sync them to a real-time database, and deploy on Netlify.
|
||||
This is an open-source markdown sync site built with React, TypeScript, and Convex. Write posts and pages in markdown, sync them to a real-time database, and deploy on Netlify.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -51,7 +51,7 @@ No REST endpoints. No cache invalidation. No WebSocket setup. The data stays in
|
||||
|
||||
Markdown files in your repo are simpler than a CMS:
|
||||
|
||||
- Version controlled with git
|
||||
- Version controlled in git (commit, diff, roll back)
|
||||
- Edit with any text editor
|
||||
- AI agents can create and modify posts
|
||||
- No separate login or admin panel
|
||||
|
||||
@@ -7,12 +7,15 @@ published: true
|
||||
tags: ["tutorial", "markdown", "cursor", "publishing"]
|
||||
readTime: "3 min read"
|
||||
featured: true
|
||||
featuredOrder: 2
|
||||
featuredOrder: 3
|
||||
image: "/images/matthew-smith-Rfflri94rs8-unsplash.jpg"
|
||||
excerpt: "Quick guide to writing and publishing markdown posts with npm run sync."
|
||||
---
|
||||
|
||||
# How to Publish a Blog Post
|
||||
|
||||

|
||||
|
||||
Your blog is set up. Now you want to publish. This guide walks through writing a markdown post and syncing it to your live site using Cursor.
|
||||
|
||||
## Create a New Post
|
||||
@@ -42,7 +45,7 @@ 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) |
|
||||
@@ -50,7 +53,7 @@ readTime: "5 min read"
|
||||
| `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 |
|
||||
| `image` | No | OG image for social sharing and featured card thumb |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower first) |
|
||||
| `excerpt` | No | Short description for card view |
|
||||
@@ -230,15 +233,19 @@ To show a post or page in the homepage featured section, add these fields to fro
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "A short description for the card view."
|
||||
image: "/images/my-thumbnail.png"
|
||||
```
|
||||
|
||||
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 |
|
||||
| `image` | Thumbnail for card view (displays as square) |
|
||||
|
||||
**Thumbnail images:** In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to fit. Square thumbnails: 400x400px minimum (800x800px for retina).
|
||||
|
||||
## Updating siteConfig
|
||||
|
||||
@@ -288,6 +295,24 @@ There is no `npm run import:prod` because the import step only creates local fil
|
||||
|
||||
**Setup:** Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local`. Get a key from [firecrawl.dev](https://firecrawl.dev).
|
||||
|
||||
## Raw Markdown Files
|
||||
|
||||
When you run `npm run sync` (development) or `npm run sync:prod` (production), the script also generates static `.md` files in `public/raw/`. These are accessible at `/raw/{slug}.md` for any post or page.
|
||||
|
||||
**Example URLs:**
|
||||
|
||||
- `/raw/setup-guide.md`
|
||||
- `/raw/about.md`
|
||||
- `/raw/how-to-publish.md`
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Share raw markdown with AI agents
|
||||
- View the source of any post
|
||||
- Link directly to markdown for LLM ingestion
|
||||
|
||||
The Copy Page dropdown on each post includes a "View as Markdown" option that opens the raw file.
|
||||
|
||||
## Summary
|
||||
|
||||
Publishing is three steps:
|
||||
@@ -296,4 +321,4 @@ Publishing is three steps:
|
||||
2. Run `npm run sync`
|
||||
3. Done
|
||||
|
||||
The Convex database updates immediately. Your site reflects changes in real time. No waiting for builds or deployments.
|
||||
The Convex database updates immediately. Static raw markdown files are generated. Your site reflects changes in real time. No waiting for builds or deployments.
|
||||
|
||||
@@ -6,6 +6,9 @@ slug: "markdown-with-code-examples"
|
||||
published: true
|
||||
tags: ["markdown", "tutorial", "code"]
|
||||
readTime: "5 min read"
|
||||
featured: true
|
||||
featuredOrder: 5
|
||||
image: "/images/markdown.png"
|
||||
---
|
||||
|
||||
# Writing Markdown with Code Examples
|
||||
@@ -118,7 +121,7 @@ Reference files with inline code: `convex/schema.ts`, `src/pages/Home.tsx`.
|
||||
## Tables
|
||||
|
||||
| Command | Description |
|
||||
| -------------------- | ------------------------------ |
|
||||
| ------------------- | ------------------------------ |
|
||||
| `npm run dev` | Start development server |
|
||||
| `npm run build` | Build for production |
|
||||
| `npm run sync` | Sync markdown to Convex (dev) |
|
||||
@@ -143,7 +146,7 @@ Reference files with inline code: `convex/schema.ts`, `src/pages/Home.tsx`.
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> Markdown files in your repo are simpler than a CMS. Version controlled, AI-editable, and no separate admin panel.
|
||||
> Markdown files in your repo are simpler than a CMS. Commit changes, review diffs, roll back anytime. AI agents can create posts programmatically. No admin panel needed.
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -7,14 +7,11 @@ published: true
|
||||
tags: ["features", "search", "convex", "updates"]
|
||||
readTime: "4 min read"
|
||||
featured: true
|
||||
featuredOrder: 0
|
||||
featuredOrder: 5
|
||||
image: "/images/v16.png"
|
||||
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.
|
||||
|
||||
149
content/blog/raw-markdown-and-copy-improvements.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: "v1.7 to v1.10 - Mobile menu, scroll-to-top, and fork configuration"
|
||||
description: "New features for mobile navigation, scroll-to-top button, fork configuration documentation, sharing content with AI tools, and improved table styling."
|
||||
date: "2025-12-20"
|
||||
slug: "raw-markdown-and-copy-improvements"
|
||||
published: true
|
||||
tags: ["features", "markdown", "updates", "mobile", "configuration"]
|
||||
readTime: "5 min read"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
image: "/images/v17.png"
|
||||
excerpt: "Mobile menu, scroll-to-top button, fork configuration, raw markdown files, and Generate Skill for AI agents."
|
||||
---
|
||||
|
||||
## Fork configuration (v1.10.0)
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
| ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site name, URL, description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/openapi.yaml` | Server URL, site name in examples |
|
||||
| `public/.well-known/ai-plugin.json` | Site name, descriptions |
|
||||
|
||||
These constants affect RSS feeds, API responses, sitemaps, and social sharing metadata.
|
||||
|
||||
## Scroll-to-top button (v1.9.0)
|
||||
|
||||
A scroll-to-top button now appears after scrolling 300px. Configure it in `src/components/Layout.tsx`:
|
||||
|
||||
```typescript
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
The button uses the Phosphor ArrowUp icon and works with all four themes. It uses a passive scroll listener for performance and includes a fade-in animation.
|
||||
|
||||
## Mobile menu (v1.8.0)
|
||||
|
||||
The site now includes a mobile menu with hamburger navigation for smaller screens. On mobile and tablet views, a hamburger icon appears in the top navigation. Tap it to open a slide-out drawer with all page navigation links.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Smooth CSS transform animations
|
||||
- Keyboard accessible (press Escape to close)
|
||||
- Focus trap for screen reader support
|
||||
- Home link at the bottom of the drawer
|
||||
- Auto-closes when navigating to a new page
|
||||
|
||||
The desktop navigation remains unchanged. The mobile menu only appears on screens below 1024px.
|
||||
|
||||
## Static raw markdown files
|
||||
|
||||
Every published post and page now gets a static `.md` file at `/raw/{slug}.md`. These files are generated automatically when you run `npm run sync`.
|
||||
|
||||
**Example URLs:**
|
||||
|
||||
- `/raw/setup-guide.md`
|
||||
- `/raw/about.md`
|
||||
- `/raw/how-to-publish.md`
|
||||
|
||||
Each file includes a metadata header with type, date, reading time, and tags. The content matches exactly what you see on the page.
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Share raw markdown with AI agents
|
||||
- Link directly to source content for LLM ingestion
|
||||
- View the markdown source of any post
|
||||
|
||||
## View as Markdown in CopyPageDropdown
|
||||
|
||||
The Copy Page dropdown now includes a "View as Markdown" option. Click it to open the raw `.md` file in a new tab.
|
||||
|
||||
This joins the existing options:
|
||||
|
||||
- Copy page (copies formatted markdown to clipboard)
|
||||
- Open in ChatGPT
|
||||
- Open in Claude
|
||||
- Open in Perplexity (new)
|
||||
|
||||
## Perplexity integration
|
||||
|
||||
Perplexity is now available as an AI service option. Click "Open in Perplexity" to send the full article content directly to Perplexity for research and analysis.
|
||||
|
||||
Like the other AI options, if the URL gets too long, the content is copied to your clipboard and Perplexity opens in a new tab. Paste to continue.
|
||||
|
||||
## Featured images
|
||||
|
||||
Posts and pages can now include a featured image that displays in the card view on the homepage.
|
||||
|
||||
Add to your frontmatter:
|
||||
|
||||
```yaml
|
||||
image: "/images/my-thumbnail.png"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
```
|
||||
|
||||
The image displays as a square thumbnail above the title in card view. Non-square images are automatically cropped to center. Recommended size: 400x400px minimum (800x800px for retina).
|
||||
|
||||
## Improved markdown table styling
|
||||
|
||||
Tables now render with GitHub-style formatting across all four themes:
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ----------------------- |
|
||||
| Borders | Clean lines |
|
||||
| Mobile | Horizontal scroll |
|
||||
| Hover | Row highlighting |
|
||||
| Themes | Dark, light, tan, cloud |
|
||||
|
||||
Tables adapt to each theme with proper alternating row colors and hover states.
|
||||
|
||||
## Generate Skill
|
||||
|
||||
The CopyPageDropdown now includes a Generate Skill option. Click it to download the current post or page as an AI agent skill file.
|
||||
|
||||
The skill file includes:
|
||||
|
||||
- Metadata section with title, description, and tags
|
||||
- When to use section describing scenarios for the skill
|
||||
- Instructions section with the full content
|
||||
|
||||
The file downloads as `{slug}-skill.md`. Use these skill files to train AI agents or add context to your workflows.
|
||||
|
||||
## Summary
|
||||
|
||||
These updates improve navigation, configuration, and sharing with AI tools:
|
||||
|
||||
1. **Fork configuration** documentation for all 9 site files
|
||||
2. **Scroll-to-top button** with configurable threshold
|
||||
3. **Mobile menu** with slide-out drawer for smaller screens
|
||||
4. **Raw markdown files** at `/raw/{slug}.md` for direct access
|
||||
5. **View as Markdown** option in CopyPageDropdown
|
||||
6. **Perplexity** added alongside ChatGPT and Claude
|
||||
7. **Generate Skill** for AI agent training
|
||||
8. **Featured images** for visual card layouts
|
||||
9. **Better tables** with responsive styling
|
||||
|
||||
All features work across all four themes and are mobile responsive. Run `npm run sync` for development and `npm run sync:prod` for production to update your site with these changes.
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
title: "Fork and Deploy Your Own Markdown Blog"
|
||||
description: "Step-by-step guide to fork this blog, set up Convex backend, and deploy to Netlify in under 10 minutes."
|
||||
title: "Setup Guide - Fork and Deploy Your Own Markdown Site"
|
||||
description: "Step-by-step guide to fork this markdown sync site, set up Convex backend, and deploy to Netlify in under 10 minutes."
|
||||
date: "2025-01-14"
|
||||
slug: "setup-guide"
|
||||
published: true
|
||||
tags: ["convex", "netlify", "tutorial", "deployment"]
|
||||
readTime: "8 min read"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
featuredOrder: 3
|
||||
image: "/public/images/setupguide.png"
|
||||
excerpt: "Complete guide to fork, set up, and deploy your own markdown blog in under 10 minutes."
|
||||
---
|
||||
|
||||
@@ -42,12 +43,15 @@ This guide walks you through forking [this markdown site](https://github.com/way
|
||||
- [Environment Files](#environment-files)
|
||||
- [When to Sync vs Deploy](#when-to-sync-vs-deploy)
|
||||
- [Customizing Your Blog](#customizing-your-blog)
|
||||
- [Files to Update When Forking](#files-to-update-when-forking)
|
||||
- [Update Backend Configuration](#update-backend-configuration)
|
||||
- [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)
|
||||
- [Scroll-to-top button](#scroll-to-top-button)
|
||||
- [Change the Default Theme](#change-the-default-theme)
|
||||
- [Change the Font](#change-the-font)
|
||||
- [Add Static Pages (Optional)](#add-static-pages-optional)
|
||||
@@ -57,6 +61,8 @@ This guide walks you through forking [this markdown site](https://github.com/way
|
||||
- [Using Search](#using-search)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Real-time Stats](#real-time-stats)
|
||||
- [Mobile Navigation](#mobile-navigation)
|
||||
- [Copy Page Dropdown](#copy-page-dropdown)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
- [Import External Content](#import-external-content)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -143,6 +149,7 @@ export default defineSchema({
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
lastSyncedAt: v.number(),
|
||||
@@ -234,7 +241,7 @@ npm run deploy
|
||||
### Option B: Netlify Dashboard
|
||||
|
||||
1. Go to [app.netlify.com](https://app.netlify.com)
|
||||
2. Click "Add new site" then "Import an existing project"
|
||||
2. Click "Add new site" then "wImport an existing project"
|
||||
3. Connect your GitHub repository
|
||||
4. Configure build settings:
|
||||
- Build command: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'`
|
||||
@@ -387,6 +394,49 @@ Both files are gitignored. Each developer creates their own local environment fi
|
||||
|
||||
## Customizing Your Blog
|
||||
|
||||
### Files to Update When Forking
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
| ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site name, URL, description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/openapi.yaml` | Server URL, site name in examples |
|
||||
| `public/.well-known/ai-plugin.json` | Site name, descriptions |
|
||||
|
||||
### Update Backend Configuration
|
||||
|
||||
These constants affect RSS feeds, API responses, sitemaps, and social sharing metadata.
|
||||
|
||||
**convex/http.ts:**
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
```
|
||||
|
||||
**convex/rss.ts:**
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_TITLE = "Your Site Name";
|
||||
const SITE_DESCRIPTION = "Your site description for RSS feeds.";
|
||||
```
|
||||
|
||||
**src/pages/Post.tsx:**
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
```
|
||||
|
||||
### Change the Favicon
|
||||
|
||||
Replace `public/favicon.svg` with your own SVG icon. The default is a rounded square with the letter "m":
|
||||
@@ -479,13 +529,25 @@ Add these fields to any post or page frontmatter:
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "A short description that appears on the card."
|
||||
image: "/images/my-thumbnail.png"
|
||||
```
|
||||
|
||||
Then run `npm run sync`. The post 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 |
|
||||
| `image` | Thumbnail for card view (displays as square) |
|
||||
|
||||
**Thumbnail images:** In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to center. Square thumbnails: 400x400px minimum (800x800px for retina).
|
||||
|
||||
**Posts without images:** Cards display without the image area. The card shows just the title and excerpt with adjusted padding.
|
||||
|
||||
**Order featured items:**
|
||||
|
||||
Use `featuredOrder` to control display order. Lower numbers appear first. Posts and pages are sorted together.
|
||||
Use `featuredOrder` to control display order. Lower numbers appear first. Posts and pages are sorted together. Items without `featuredOrder` appear after numbered items, sorted by creation time.
|
||||
|
||||
**Toggle view mode:**
|
||||
|
||||
@@ -543,6 +605,26 @@ Delete the sample files from `public/images/logos/` and clear the images array,
|
||||
|
||||
The gallery uses CSS animations for smooth infinite scrolling. Logos display in grayscale and colorize on hover.
|
||||
|
||||
### Scroll-to-top button
|
||||
|
||||
A scroll-to-top button appears after scrolling down on posts and pages. Configure it in `src/components/Layout.tsx`:
|
||||
|
||||
```typescript
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `threshold` | Pixels scrolled before button appears |
|
||||
| `smooth` | `true` for smooth scroll, `false` for jump |
|
||||
|
||||
The button uses Phosphor ArrowUp icon and works with all four themes. It uses a passive scroll listener for performance.
|
||||
|
||||
### Change the Default Theme
|
||||
|
||||
Edit `src/context/ThemeContext.tsx`:
|
||||
@@ -655,6 +737,27 @@ How it works:
|
||||
- A cron job cleans up stale sessions every 5 minutes
|
||||
- No personal data is stored (only anonymous UUIDs)
|
||||
|
||||
## Mobile Navigation
|
||||
|
||||
On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route.
|
||||
|
||||
## Copy Page Dropdown
|
||||
|
||||
Each post and page includes a share dropdown with options for AI tools:
|
||||
|
||||
| Option | Description |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with article content |
|
||||
| Open in Claude | Opens Claude with article content |
|
||||
| Open in Perplexity | Opens Perplexity for research with content |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
|
||||
|
||||
**Generate Skill** formats the content as an AI agent skill file with metadata, when to use, and instructions sections.
|
||||
|
||||
**Long content:** If content exceeds URL limits, it copies to clipboard and opens the AI service in a new tab. Paste to continue.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Your blog includes these API endpoints for search engines and AI:
|
||||
@@ -669,6 +772,7 @@ Your blog includes these API endpoints for search engines and AI:
|
||||
| `/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 |
|
||||
| `/raw/{slug}.md` | Static raw markdown file |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
@@ -759,11 +863,15 @@ See [netlify-deploy-fix.md](https://github.com/waynesutton/markdown-site/blob/ma
|
||||
|
||||
```
|
||||
markdown-site/
|
||||
├── content/blog/ # Markdown blog posts
|
||||
├── content/
|
||||
│ ├── blog/ # Markdown blog posts
|
||||
│ └── pages/ # Static pages (About, Docs, etc.)
|
||||
├── convex/ # Convex backend functions
|
||||
│ ├── http.ts # HTTP endpoints
|
||||
│ ├── posts.ts # Post queries/mutations
|
||||
│ ├── pages.ts # Page queries/mutations
|
||||
│ ├── rss.ts # RSS feed generation
|
||||
│ ├── stats.ts # Analytics functions
|
||||
│ └── schema.ts # Database schema
|
||||
├── netlify/
|
||||
│ └── edge-functions/ # Netlify edge functions
|
||||
@@ -771,12 +879,15 @@ markdown-site/
|
||||
│ ├── sitemap.ts # Sitemap proxy
|
||||
│ ├── api.ts # API proxy
|
||||
│ └── botMeta.ts # OG crawler detection
|
||||
├── public/ # Static assets
|
||||
├── public/
|
||||
│ ├── images/ # Static images
|
||||
│ ├── raw/ # Generated raw markdown files
|
||||
│ ├── robots.txt # Crawler rules
|
||||
│ └── llms.txt # AI agent discovery
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ ├── context/ # Theme context
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── pages/ # Page components
|
||||
│ └── styles/ # Global CSS
|
||||
├── netlify.toml # Netlify configuration
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "Learn how to add header images, inline images, and Open Graph imag
|
||||
date: "2025-01-18"
|
||||
slug: "using-images-in-posts"
|
||||
published: true
|
||||
featured: true
|
||||
featuredOrder: 3
|
||||
tags: ["images", "tutorial", "markdown", "open-graph"]
|
||||
readTime: "4 min read"
|
||||
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
|
||||
@@ -15,7 +17,10 @@ This post demonstrates how to add images to your blog posts. You can use header
|
||||
|
||||
## Header/Open Graph Images
|
||||
|
||||
The `image` field in your frontmatter sets the Open Graph image for social media previews. When someone shares your post on Twitter, LinkedIn, or Slack, this image appears in the preview card.
|
||||
The `image` field in your frontmatter serves two purposes:
|
||||
|
||||
1. **Open Graph image** for social media previews (Twitter, LinkedIn, Slack)
|
||||
2. **Thumbnail image** for featured section card view on the homepage
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -24,7 +29,24 @@ image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=63
|
||||
---
|
||||
```
|
||||
|
||||
**Recommended dimensions:** 1200x630 pixels (1.91:1 ratio)
|
||||
**Recommended dimensions:** 1200x630 pixels (1.91:1 ratio) for social sharing
|
||||
|
||||
## Featured Section Thumbnails
|
||||
|
||||
When a post or page is marked as `featured: true`, the `image` field displays as a square thumbnail in the card view.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Featured Post"
|
||||
image: "/images/thumbnail.png"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
---
|
||||
```
|
||||
|
||||
**Square display:** Non-square images are automatically cropped to fit the square thumbnail area. The crop centers on the middle of the image. For best results, use images where the main subject is centered.
|
||||
|
||||
**Square thumbnails:** 400x400px minimum (800x800px for retina)
|
||||
|
||||
## Inline Images
|
||||
|
||||
|
||||
@@ -3,16 +3,40 @@ 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."
|
||||
excerpt: "A dev sync site builder for developers who want version-controlled content with real-time delivery."
|
||||
---
|
||||
|
||||
This is a markdown site built for writers, developers, and teams who want a fast, real-time publishing workflow.
|
||||
This is a dev sync site builder for developers who want version-controlled content with real-time delivery. Write markdown locally, sync to a real-time database, and content appears instantly across all connected browsers.
|
||||
|
||||
## What makes it different
|
||||
## What makes it a dev sync system
|
||||
|
||||
Most static site generators require a rebuild every time you publish. This one does not. Write markdown, run a sync command, and your content appears instantly across all connected browsers.
|
||||
**File-based content.** All posts and pages live in `content/blog/` and `content/pages/` as markdown files with frontmatter. No database UI. No admin panel. Just files in your repo.
|
||||
|
||||
The backend runs on Convex, a reactive database that pushes updates to clients in real time. No polling. No cache invalidation. No deploy cycles for content changes.
|
||||
**CLI publishing workflow.** Write markdown locally, then run `npm run sync` (dev) or `npm run sync:prod` (production). Content appears instantly via Convex real-time sync.
|
||||
|
||||
**Version controlled.** Markdown source files live in your repo alongside code. Commit changes, review diffs, roll back like any codebase. The sync command pushes content to the database.
|
||||
|
||||
```bash
|
||||
# Edit a post, then commit and sync
|
||||
git add content/blog/my-post.md
|
||||
git commit -m "Update intro paragraph"
|
||||
npm run sync # dev
|
||||
npm run sync:prod # production
|
||||
```
|
||||
|
||||
**No admin interface.** No web UI for creating or editing content. You use your code editor and terminal.
|
||||
|
||||
## The real-time twist
|
||||
|
||||
[](https://convex.dev)
|
||||
|
||||
What separates this from a static site generator is the Convex real-time database. Once you sync content:
|
||||
|
||||
- All connected browsers update immediately
|
||||
- No rebuild or redeploy needed
|
||||
- Search, stats, and RSS update automatically
|
||||
|
||||
It's a hybrid: developer workflow for publishing + real-time delivery like a dynamic CMS.
|
||||
|
||||
## The stack
|
||||
|
||||
@@ -27,16 +51,23 @@ The backend runs on Convex, a reactive database that pushes updates to clients i
|
||||
## Features
|
||||
|
||||
- Four theme options (dark, light, tan, cloud)
|
||||
- Mobile menu with hamburger navigation on smaller screens
|
||||
- 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
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
- API endpoints for AI/LLM access
|
||||
- Copy to ChatGPT/Claude sharing
|
||||
- Copy to ChatGPT, Claude, and Perplexity sharing
|
||||
- Generate Skill option for AI agent training
|
||||
- View as Markdown option in share dropdown
|
||||
|
||||
## 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.
|
||||
- Developers who want version-controlled content
|
||||
- Teams comfortable with markdown and CLI
|
||||
- Projects where AI agents generate content programmatically
|
||||
- Sites that need real-time updates without full rebuilds
|
||||
|
||||
Fork it, customize it, ship it.
|
||||
|
||||
@@ -5,10 +5,91 @@ published: true
|
||||
order: 5
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project.
|
||||
|
||||
## v1.10.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Fork configuration documentation**
|
||||
|
||||
- Added "Files to Update When Forking" section to docs and setup guide
|
||||
- Lists all 9 configuration files users need to update when forking
|
||||
- Includes backend configuration examples for Convex files
|
||||
- Code snippets for `convex/http.ts`, `convex/rss.ts`, `src/pages/Post.tsx`
|
||||
|
||||
**Site branding updates**
|
||||
|
||||
- Updated `public/robots.txt` with sitemap URL and header
|
||||
- Updated `public/llms.txt` with site name and description
|
||||
- Updated `public/.well-known/ai-plugin.json` for AI plugins
|
||||
- Updated `public/openapi.yaml` API title and site name
|
||||
- Updated `convex/http.ts` SITE_URL and SITE_NAME constants
|
||||
|
||||
Same fork documentation added to README.md for discoverability.
|
||||
|
||||
## v1.9.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Scroll-to-top button**
|
||||
|
||||
- Appears after scrolling 300px (configurable)
|
||||
- Uses Phosphor ArrowUp icon for consistency
|
||||
- Smooth scroll animation (configurable)
|
||||
- Works with all four themes (dark, light, tan, cloud)
|
||||
- Enabled by default (can be disabled in Layout.tsx)
|
||||
- Fade-in animation when appearing
|
||||
- Responsive sizing for mobile devices
|
||||
|
||||
New component: `src/components/ScrollToTop.tsx`
|
||||
|
||||
Configuration via `ScrollToTopConfig` interface in `src/components/Layout.tsx`. Uses passive scroll listener for performance.
|
||||
|
||||
## v1.8.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Mobile menu and Generate Skill feature**
|
||||
|
||||
- Mobile menu with hamburger navigation
|
||||
- Slide-out drawer on mobile and tablet views
|
||||
- Accessible with keyboard navigation (Escape to close)
|
||||
- Focus trap for screen reader support
|
||||
- Page links and Home link in drawer
|
||||
- Auto-closes on route change
|
||||
- Generate Skill option in CopyPageDropdown
|
||||
- Formats post/page content as an AI agent skill file
|
||||
- Downloads as `{slug}-skill.md` with skill structure
|
||||
- Includes metadata, when to use, and instructions sections
|
||||
|
||||
New component: `MobileMenu.tsx` with HamburgerButton
|
||||
|
||||
## v1.7.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Raw markdown files and CopyPageDropdown improvements**
|
||||
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
- Generated during `npm run sync` and `npm run sync:prod` in `public/raw/` directory
|
||||
- Each published post and page gets a corresponding static `.md` file
|
||||
- Includes metadata header (type, date, reading time, tags)
|
||||
- View as Markdown option in CopyPageDropdown
|
||||
- Opens raw `.md` file in new tab
|
||||
- Available on all post and page views
|
||||
- Perplexity added to AI service options in CopyPageDropdown
|
||||
- Research articles directly in Perplexity with full content
|
||||
- Featured image support for posts and pages
|
||||
- `image` field displays as square thumbnail in card view
|
||||
- Non-square images automatically cropped to center
|
||||
- Improved markdown table CSS styling
|
||||
- GitHub-style tables with proper borders
|
||||
- Mobile responsive with horizontal scroll
|
||||
- Theme-aware alternating row colors
|
||||
|
||||
New files: `public/raw/*.md` (generated), updated `_redirects`
|
||||
|
||||
## v1.6.1
|
||||
|
||||
Released December 18, 2025
|
||||
@@ -158,4 +239,3 @@ Released December 14, 2025
|
||||
- react-markdown for rendering
|
||||
- react-syntax-highlighter for code blocks
|
||||
- Netlify deployment with edge functions
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ published: true
|
||||
order: 3
|
||||
---
|
||||
|
||||
You found the contact page. Nice.
|
||||
You found the contact page. Nice
|
||||
|
||||
## The technical way
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ markdown-site/
|
||||
│ └── styles/ # CSS
|
||||
├── public/
|
||||
│ ├── images/ # Static images
|
||||
│ ├── raw/ # Generated raw markdown files
|
||||
│ ├── robots.txt # Crawler rules
|
||||
│ └── llms.txt # AI discovery
|
||||
└── netlify.toml # Deployment config
|
||||
@@ -82,7 +83,7 @@ Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ------------------------------------- |
|
||||
| --------------- | -------- | ------------------------------------ |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
@@ -90,7 +91,7 @@ Content here...
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | Open Graph image |
|
||||
| `image` | No | OG image and featured card thumbnail |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
@@ -111,12 +112,13 @@ 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) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `image` | No | Thumbnail for featured card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
|
||||
@@ -133,7 +135,7 @@ 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) |
|
||||
@@ -146,7 +148,46 @@ npm run sync:prod
|
||||
|
||||
## Configuration
|
||||
|
||||
### Site settings
|
||||
### Site and backend settings
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
|------|----------------|
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site name, URL, description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/openapi.yaml` | Server URL, site name in examples |
|
||||
| `public/.well-known/ai-plugin.json` | Site name, descriptions |
|
||||
|
||||
**Backend constants** (`convex/http.ts` and `convex/rss.ts`):
|
||||
|
||||
```typescript
|
||||
// convex/http.ts
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
|
||||
// convex/rss.ts
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_TITLE = "Your Site Name";
|
||||
const SITE_DESCRIPTION = "Your site description for RSS feeds.";
|
||||
```
|
||||
|
||||
**Post page constants** (`src/pages/Post.tsx`):
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
```
|
||||
|
||||
These constants affect RSS feeds, API responses, sitemaps, and social sharing metadata.
|
||||
|
||||
### Homepage settings
|
||||
|
||||
Edit `src/pages/Home.tsx`:
|
||||
|
||||
@@ -167,9 +208,7 @@ const siteConfig = {
|
||||
// Logo gallery (with clickable links)
|
||||
logoGallery: {
|
||||
enabled: true, // false to hide
|
||||
images: [
|
||||
{ src: "/images/logos/logo.svg", href: "https://example.com" },
|
||||
],
|
||||
images: [{ src: "/images/logos/logo.svg", href: "https://example.com" }],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
title: "Trusted by",
|
||||
@@ -193,15 +232,23 @@ Posts and pages appear in the featured section when marked with `featured: true`
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
excerpt: "Short description for card view."
|
||||
image: "/images/thumbnail.png"
|
||||
```
|
||||
|
||||
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 |
|
||||
| `image` | Thumbnail for card view (displays as square) |
|
||||
|
||||
**Thumbnail images:** In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to center. Square thumbnails: 400x400px minimum (800x800px for retina).
|
||||
|
||||
**Posts without images:** Cards display without the image area. The card shows just the title and excerpt with adjusted padding.
|
||||
|
||||
**Ordering:** Items with `featuredOrder` appear first (lower numbers first). Items without `featuredOrder` appear after, sorted by creation time.
|
||||
|
||||
**Display options (in siteConfig):**
|
||||
|
||||
@@ -232,7 +279,7 @@ logoGallery: {
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| ---------- | --------------------------------------------- |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `images` | Array of `{ src, href }` objects |
|
||||
| `position` | `'above-footer'` or `'below-featured'` |
|
||||
@@ -249,6 +296,26 @@ logoGallery: {
|
||||
|
||||
**To remove samples:** Delete files from `public/images/logos/` or clear the images array.
|
||||
|
||||
### Scroll-to-top button
|
||||
|
||||
A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`:
|
||||
|
||||
```typescript
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `threshold` | Pixels scrolled before button appears |
|
||||
| `smooth` | `true` for smooth scroll, `false` for jump |
|
||||
|
||||
Uses Phosphor ArrowUp icon and works with all themes.
|
||||
|
||||
### Theme
|
||||
|
||||
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
|
||||
@@ -297,6 +364,33 @@ Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. Cl
|
||||
|
||||
Search uses Convex full text search indexes. No configuration needed.
|
||||
|
||||
## Mobile menu
|
||||
|
||||
On mobile and tablet screens, a hamburger menu provides navigation. The menu slides out from the left with:
|
||||
|
||||
- Keyboard navigation (Escape to close)
|
||||
- Focus trap for accessibility
|
||||
- Auto-close on route change
|
||||
|
||||
The menu appears automatically on screens under 768px wide.
|
||||
|
||||
## Copy Page dropdown
|
||||
|
||||
Each post and page includes a share dropdown with options:
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with article content |
|
||||
| Open in Claude | Opens Claude with article content |
|
||||
| Open in Perplexity | Opens Perplexity for research with content |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
|
||||
|
||||
**Generate Skill:** Formats the content as an AI agent skill file with metadata, when to use, and instructions sections.
|
||||
|
||||
**Long content:** If content exceeds URL limits, it copies to clipboard and opens the AI service in a new tab. Paste to continue.
|
||||
|
||||
## Real-time stats
|
||||
|
||||
The `/stats` page displays real-time analytics:
|
||||
@@ -311,7 +405,7 @@ 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) |
|
||||
@@ -320,10 +414,41 @@ All stats update automatically via Convex subscriptions.
|
||||
| `/api/post?slug=xxx` | Single post (JSON) |
|
||||
| `/api/post?slug=xxx&format=md` | Single post (markdown) |
|
||||
| `/api/export` | All posts with full content |
|
||||
| `/raw/{slug}.md` | Static raw markdown file |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Raw markdown files
|
||||
|
||||
When you run `npm run sync` (development) or `npm run sync:prod` (production), static `.md` files are generated in `public/raw/` for each published post and page.
|
||||
|
||||
**Access pattern:** `/raw/{slug}.md`
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `/raw/setup-guide.md`
|
||||
- `/raw/about.md`
|
||||
|
||||
These files include a metadata header with type, date, reading time, and tags. Access via the "View as Markdown" option in the Copy Page dropdown.
|
||||
|
||||
## Markdown tables
|
||||
|
||||
Tables render with GitHub-style formatting:
|
||||
|
||||
- Clean borders across all themes
|
||||
- Mobile responsive with horizontal scroll
|
||||
- Theme-aware alternating row colors
|
||||
- Hover states for readability
|
||||
|
||||
Example:
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ------ |
|
||||
| Borders | Clean |
|
||||
| Mobile | Scroll |
|
||||
| Themes | All |
|
||||
|
||||
## Import external content
|
||||
|
||||
Use Firecrawl to import articles from external URLs:
|
||||
@@ -402,6 +527,7 @@ export default defineSchema({
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()), // For card view
|
||||
image: v.optional(v.string()), // Thumbnail for featured cards
|
||||
featured: v.optional(v.boolean()), // Show in featured section
|
||||
featuredOrder: v.optional(v.number()), // Order in featured (lower = first)
|
||||
lastSyncedAt: v.number(),
|
||||
|
||||
@@ -7,7 +7,7 @@ const http = httpRouter();
|
||||
|
||||
// Site configuration
|
||||
const SITE_URL = process.env.SITE_URL || "https://markdowncms.netlify.app";
|
||||
const SITE_NAME = "Markdown Site";
|
||||
const SITE_NAME = "markdown sync site";
|
||||
|
||||
// RSS feed endpoint (descriptions only)
|
||||
http.route({
|
||||
@@ -72,7 +72,7 @@ http.route({
|
||||
const response = {
|
||||
site: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description: "Developer and writer. Building with Convex and AI.",
|
||||
description: "An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify.",
|
||||
posts: posts.map((post) => ({
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
@@ -194,7 +194,7 @@ http.route({
|
||||
const response = {
|
||||
site: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description: "Open source markdown blog with real-time sync.",
|
||||
description: "An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify.",
|
||||
exportedAt: new Date().toISOString(),
|
||||
totalPosts: fullPosts.length,
|
||||
posts: fullPosts,
|
||||
@@ -228,8 +228,8 @@ function generatePostMetaHtml(post: {
|
||||
date: string;
|
||||
readTime?: string;
|
||||
}): string {
|
||||
const siteUrl = process.env.SITE_URL || "https://your-blog.netlify.app";
|
||||
const siteName = "Wayne Sutton";
|
||||
const siteUrl = process.env.SITE_URL || "https://markdowncms.netlify.app";
|
||||
const siteName = "markdown sync site";
|
||||
const defaultImage = `${siteUrl}/og-image.png`;
|
||||
const canonicalUrl = `${siteUrl}/${post.slug}`;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const getAllPages = query({
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
@@ -37,6 +38,7 @@ export const getAllPages = query({
|
||||
published: page.published,
|
||||
order: page.order,
|
||||
excerpt: page.excerpt,
|
||||
image: page.image,
|
||||
featured: page.featured,
|
||||
featuredOrder: page.featuredOrder,
|
||||
}));
|
||||
@@ -52,6 +54,7 @@ export const getFeaturedPages = query({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
@@ -75,6 +78,7 @@ export const getFeaturedPages = query({
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
excerpt: page.excerpt,
|
||||
image: page.image,
|
||||
featuredOrder: page.featuredOrder,
|
||||
}));
|
||||
},
|
||||
@@ -94,6 +98,7 @@ export const getPageBySlug = query({
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
@@ -117,6 +122,7 @@ export const getPageBySlug = query({
|
||||
published: page.published,
|
||||
order: page.order,
|
||||
excerpt: page.excerpt,
|
||||
image: page.image,
|
||||
featured: page.featured,
|
||||
featuredOrder: page.featuredOrder,
|
||||
};
|
||||
@@ -134,6 +140,7 @@ export const syncPagesPublic = mutation({
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()),
|
||||
excerpt: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
featured: v.optional(v.boolean()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
@@ -168,6 +175,7 @@ export const syncPagesPublic = mutation({
|
||||
published: page.published,
|
||||
order: page.order,
|
||||
excerpt: page.excerpt,
|
||||
image: page.image,
|
||||
featured: page.featured,
|
||||
featuredOrder: page.featuredOrder,
|
||||
lastSyncedAt: now,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const getFeaturedPosts = query({
|
||||
title: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
description: v.string(),
|
||||
image: v.optional(v.string()),
|
||||
featuredOrder: v.optional(v.number()),
|
||||
}),
|
||||
),
|
||||
@@ -85,6 +86,7 @@ export const getFeaturedPosts = query({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
description: post.description,
|
||||
image: post.image,
|
||||
featuredOrder: post.featuredOrder,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@ import { api } from "./_generated/api";
|
||||
|
||||
// Site configuration for RSS feed
|
||||
const SITE_URL = process.env.SITE_URL || "https://markdowncms.netlify.app";
|
||||
const SITE_TITLE = "Markdown Site";
|
||||
const SITE_TITLE = "markdown sync site";
|
||||
const SITE_DESCRIPTION =
|
||||
"An open source markdown site powered by Convex and Netlify.";
|
||||
"An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify.";
|
||||
|
||||
// Escape XML special characters
|
||||
function escapeXml(text: string): string {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineSchema({
|
||||
published: v.boolean(),
|
||||
order: v.optional(v.number()), // Display order in nav
|
||||
excerpt: v.optional(v.string()), // Short excerpt for card view
|
||||
image: v.optional(v.string()), // Thumbnail/OG image URL for featured cards
|
||||
featured: v.optional(v.boolean()), // Show in featured section
|
||||
featuredOrder: v.optional(v.number()), // Order in featured section (lower = first)
|
||||
lastSyncedAt: v.number(),
|
||||
|
||||
40
files.md
@@ -30,23 +30,25 @@ A brief description of each file in the codebase.
|
||||
### Pages (`src/pages/`)
|
||||
|
||||
| File | Description |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| `Home.tsx` | Landing page with siteConfig, featured section, logo gallery |
|
||||
| `Post.tsx` | Individual blog post view with JSON-LD injection |
|
||||
| ----------- | ----------------------------------------------------------------- |
|
||||
| `Home.tsx` | Landing page with siteConfig (update name/title/bio when forking) |
|
||||
| `Post.tsx` | Individual blog post view (update SITE_URL/SITE_NAME when forking) |
|
||||
| `Stats.tsx` | Real-time analytics dashboard with visitor stats |
|
||||
|
||||
### Components (`src/components/`)
|
||||
|
||||
| File | Description |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| `Layout.tsx` | Page wrapper with search button and theme toggle |
|
||||
| `Layout.tsx` | Page wrapper with search button, theme toggle, mobile menu, and scroll-to-top |
|
||||
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
|
||||
| `PostList.tsx` | Year-grouped blog post list |
|
||||
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude) |
|
||||
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude, Perplexity) with View as Markdown and Generate Skill options |
|
||||
| `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 |
|
||||
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button |
|
||||
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
|
||||
|
||||
### Context (`src/context/`)
|
||||
|
||||
@@ -69,15 +71,15 @@ A brief description of each file in the codebase.
|
||||
## Convex Backend (`convex/`)
|
||||
|
||||
| File | Description |
|
||||
| ------------------ | ------------------------------------------------------------- |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) |
|
||||
| `posts.ts` | Queries and mutations for blog posts, view counts |
|
||||
| `pages.ts` | Queries and mutations for static pages |
|
||||
| `search.ts` | Full text search queries across posts and pages |
|
||||
| `stats.ts` | Real-time stats queries, page view recording, session heartbeat |
|
||||
| `crons.ts` | Cron job for stale session cleanup |
|
||||
| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
|
||||
| `rss.ts` | RSS feed generation (standard and full content) |
|
||||
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking) |
|
||||
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking) |
|
||||
| `convex.config.ts` | Convex app configuration |
|
||||
| `tsconfig.json` | Convex TypeScript configuration |
|
||||
|
||||
@@ -151,15 +153,27 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `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 |
|
||||
| `robots.txt` | Crawler rules for search engines and AI bots (update sitemap URL when forking) |
|
||||
| `llms.txt` | AI agent discovery file (update site name/URL when forking) |
|
||||
| `openapi.yaml` | OpenAPI 3.0 specification (update API title when forking) |
|
||||
|
||||
### Raw Markdown Files (`public/raw/`)
|
||||
|
||||
Static markdown files generated during `npm run sync`. Each published post and page gets a corresponding `.md` file for direct access by users, search engines, and AI agents.
|
||||
|
||||
| File Pattern | Description |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `{slug}.md` | Static markdown file for each post/page |
|
||||
|
||||
Access via `/raw/{slug}.md` (e.g., `/raw/setup-guide.md`).
|
||||
|
||||
Files include a metadata header with type (post/page), date, reading time, and tags. The CopyPageDropdown includes a "View as Markdown" option that links directly to these files.
|
||||
|
||||
### AI Plugin (`public/.well-known/`)
|
||||
|
||||
| File | Description |
|
||||
| ----------------- | ------------------------------------ |
|
||||
| `ai-plugin.json` | AI plugin manifest for tool integration |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `ai-plugin.json` | AI plugin manifest (update name/description when forking) |
|
||||
|
||||
### Images (`public/images/`)
|
||||
|
||||
|
||||
30
index.html
@@ -8,12 +8,12 @@
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="An open source markdown site powered by Convex and Netlify. Fork it, customize it, ship it."
|
||||
content="An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify."
|
||||
/>
|
||||
<meta name="author" content="Markdown Site" />
|
||||
<meta name="author" content="markdown sync site" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="markdown site, Convex, Netlify, React, TypeScript, open source, real-time"
|
||||
content="markdown site, Convex, Netlify, React, TypeScript, open source, real-time, sync"
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
@@ -21,17 +21,14 @@
|
||||
<meta name="theme-color" content="#faf8f5" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Markdown Site - Real-time Site with Convex"
|
||||
/>
|
||||
<meta property="og:title" content="markdown sync site" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="An open source markdown site powered by Convex and Netlify. Fork it, customize it, ship it."
|
||||
content="An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://markdowncms.netlify.app/" />
|
||||
<meta property="og:site_name" content="Markdown Site" />
|
||||
<meta property="og:site_name" content="markdown sync site" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://markdowncms.netlify.app/images/og-default.svg"
|
||||
@@ -41,13 +38,10 @@
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:domain" content="markdowncms.netlify.app" />
|
||||
<meta property="twitter:url" content="https://markdowncms.netlify.app/" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Markdown Site - Real-time Site with Convex"
|
||||
/>
|
||||
<meta name="twitter:title" content="markdown sync site" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="An open source markdown site powered by Convex and Netlify. Fork it, customize it, ship it."
|
||||
content="An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
@@ -76,12 +70,12 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Markdown Site",
|
||||
"name": "markdown sync site",
|
||||
"url": "https://markdowncms.netlify.app",
|
||||
"description": "An open source markdown site powered by Convex and Netlify.",
|
||||
"description": "An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify.",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "Markdown Site",
|
||||
"name": "markdown sync site",
|
||||
"url": "https://markdowncms.netlify.app"
|
||||
},
|
||||
"potentialAction": {
|
||||
@@ -92,7 +86,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<title>Markdown Site - Real-time Site with Convex</title>
|
||||
<title>markdown "sync" site</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
107
prds/security-fixes.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Security Fixes Implementation Plan
|
||||
|
||||
Based on the security audit comparing this codebase against Mintlify vulnerabilities (CVE-2025-67842/43/44/45/46), this plan addresses the identified security concerns.
|
||||
|
||||
## Summary of findings
|
||||
|
||||
| Priority | Issue | Risk | Files Affected |
|
||||
|----------|-------|------|----------------|
|
||||
| Critical | Unauthenticated `syncPostsPublic` mutation | Anyone can modify/delete posts | `convex/posts.ts` |
|
||||
| Medium | Weak session ID generation | Predictable session IDs | `src/hooks/usePageTracking.ts` |
|
||||
| Medium | No Content Security Policy | XSS/injection vectors | `netlify.toml` |
|
||||
| Low | No application rate limiting | Abuse potential | N/A (Convex handles) |
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Add secret key authentication to sync mutation
|
||||
|
||||
Protect `syncPostsPublic` in `convex/posts.ts` with an environment variable check:
|
||||
|
||||
```typescript
|
||||
export const syncPostsPublic = mutation({
|
||||
args: {
|
||||
posts: v.array(...),
|
||||
secretKey: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
// Verify secret key matches environment variable
|
||||
const expectedKey = process.env.SYNC_SECRET_KEY;
|
||||
if (expectedKey && args.secretKey !== expectedKey) {
|
||||
throw new Error("Unauthorized: Invalid sync key");
|
||||
}
|
||||
// ... existing sync logic
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Update `scripts/sync-posts.ts` to pass the secret key:
|
||||
|
||||
```typescript
|
||||
const secretKey = process.env.SYNC_SECRET_KEY;
|
||||
await client.mutation(api.posts.syncPostsPublic, { posts, secretKey });
|
||||
```
|
||||
|
||||
**Convex Dashboard action required:** Add `SYNC_SECRET_KEY` environment variable.
|
||||
|
||||
### 2. Fix weak session ID generation
|
||||
|
||||
Replace `Math.random()` with `crypto.randomUUID()` in `src/hooks/usePageTracking.ts`:
|
||||
|
||||
```typescript
|
||||
function generateSessionId(): string {
|
||||
// Use cryptographically secure random UUID
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback for older browsers (still better than Math.random)
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (crypto.getRandomValues(new Uint8Array(1))[0] % 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add Content Security Policy headers
|
||||
|
||||
Add CSP headers to `netlify.toml`:
|
||||
|
||||
```toml
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self' https://*.convex.cloud https://*.convex.site; frame-ancestors 'none'"
|
||||
```
|
||||
|
||||
## Convex Dashboard setup
|
||||
|
||||
1. Navigate to [Convex Dashboard](https://dashboard.convex.dev)
|
||||
2. Select your project
|
||||
3. Go to Settings > Environment Variables
|
||||
4. Add new variable:
|
||||
- Name: `SYNC_SECRET_KEY`
|
||||
- Value: Generate a secure random string (32+ characters)
|
||||
5. Add the same variable for both development and production deployments
|
||||
6. Update `.env.local` and `.env.production.local` with the same key
|
||||
|
||||
## Testing checklist
|
||||
|
||||
- [ ] Sync fails without secret key when `SYNC_SECRET_KEY` is set
|
||||
- [ ] Sync succeeds with correct secret key
|
||||
- [ ] Session IDs are properly generated using crypto API
|
||||
- [ ] CSP headers appear in network responses
|
||||
- [ ] Site functionality unchanged after security updates
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add secret key authentication to syncPostsPublic mutation
|
||||
- [ ] Update sync-posts.ts to pass secret key
|
||||
- [ ] Replace Math.random with crypto.randomUUID for session IDs
|
||||
- [ ] Add Content Security Policy headers to netlify.toml
|
||||
- [ ] Add SYNC_SECRET_KEY to Convex dashboard and local env files
|
||||
- [ ] Update sec-check.mdc with implementation status
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"name_for_human": "Markdown Blog",
|
||||
"name_for_model": "markdown_blog",
|
||||
"description_for_human": "A real-time markdown blog with Convex backend",
|
||||
"name_for_human": "markdown sync site",
|
||||
"name_for_model": "markdown_sync_site",
|
||||
"description_for_human": "An open-source markdown sync site you publish from the terminal with npm run sync",
|
||||
"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"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
/llms.txt /llms.txt 200
|
||||
/favicon.svg /favicon.svg 200
|
||||
|
||||
# Raw markdown files served directly
|
||||
/raw/* /raw/:splat 200
|
||||
|
||||
# SPA fallback for all other routes
|
||||
/* /index.html 200
|
||||
|
||||
|
||||
BIN
public/images/117dataimage.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/17image.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
9
public/images/logos/convex-wordmark-black.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="322" height="146" viewBox="0 0 322 146" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M55.2938 86.6648C51.9542 83.6785 50.2844 79.2644 50.2844 73.434C50.2844 67.6036 51.9866 63.1896 55.3965 60.2033C58.8009 57.2169 63.4591 55.7209 69.3655 55.7209C71.8188 55.7209 73.9858 55.8973 75.8717 56.2613C77.7577 56.6197 79.5626 57.2283 81.2864 58.0929V67.5524C78.6061 66.2157 75.5637 65.5445 72.1593 65.5445C69.1601 65.5445 66.9446 66.1417 65.5179 67.3363C64.0859 68.5308 63.3726 70.5615 63.3726 73.434C63.3726 76.2099 64.0751 78.2178 65.4855 79.4578C66.8905 80.7035 69.1169 81.3235 72.1647 81.3235C75.3908 81.3235 78.4548 80.5329 81.3621 78.9573V88.8547C78.136 90.3849 74.1155 91.1471 69.3006 91.1471C63.2969 91.1471 58.6334 89.6511 55.2938 86.6648Z" fill="#141414"/>
|
||||
<path d="M84.2698 73.4278C84.2698 67.6429 85.8369 63.246 88.9711 60.2312C92.1054 57.2165 96.8284 55.7148 103.145 55.7148C109.506 55.7148 114.261 57.2222 117.422 60.2312C120.578 63.2403 122.156 67.6429 122.156 73.4278C122.156 85.2366 115.818 91.1409 103.145 91.1409C90.5599 91.1466 84.2698 85.2422 84.2698 73.4278ZM107.679 79.4573C108.609 78.2116 109.074 76.2037 109.074 73.4335C109.074 70.7089 108.609 68.7123 107.679 67.4439C106.75 66.1754 105.237 65.544 103.145 65.544C101.103 65.544 99.6222 66.1811 98.7143 67.4439C97.8065 68.7123 97.3525 70.7089 97.3525 73.4335C97.3525 76.2094 97.8065 78.2173 98.7143 79.4573C99.6222 80.7031 101.097 81.3231 103.145 81.3231C105.237 81.3231 106.744 80.6974 107.679 79.4573Z" fill="#141414"/>
|
||||
<path d="M125.138 56.4315H137.129L137.47 59.0139C138.788 58.0583 140.469 57.2677 142.511 56.6476C144.554 56.0276 146.667 55.7148 148.85 55.7148C152.892 55.7148 155.843 56.7671 157.707 58.8717C159.571 60.9764 160.501 64.2243 160.501 68.627V90.4299H147.694V69.9865C147.694 68.4564 147.364 67.3585 146.705 66.6873C146.046 66.0161 144.943 65.6862 143.398 65.6862C142.447 65.6862 141.468 65.9137 140.469 66.3688C139.469 66.8238 138.631 67.4097 137.945 68.1264V90.4299H125.138V56.4315Z" fill="#141414"/>
|
||||
<path d="M160.538 56.4317H173.891L180.024 76.3689L186.158 56.4317H199.511L186.768 90.4301H173.275L160.538 56.4317Z" fill="#141414"/>
|
||||
<path d="M203.543 87.5061C199.695 84.4686 197.896 79.1957 197.896 73.5018C197.896 67.9558 199.328 63.3882 202.597 60.2312C205.866 57.0743 210.849 55.7148 217.139 55.7148C222.926 55.7148 227.476 57.1255 230.8 59.9468C234.118 62.7682 235.782 66.6191 235.782 71.4939V77.4494H211.427C212.032 79.2184 212.799 80.4983 214.685 81.2889C216.571 82.0796 219.203 82.4721 222.57 82.4721C224.58 82.4721 226.633 82.3071 228.719 81.9715C229.454 81.8521 230.665 81.6644 231.302 81.5222V89.7871C228.119 90.6972 223.877 91.1523 219.095 91.1523C212.659 91.1466 207.39 90.5436 203.543 87.5061ZM222.326 70.1344C222.326 68.4507 220.484 64.8273 216.782 64.8273C213.442 64.8273 211.238 68.3938 211.238 70.1344H222.326Z" fill="#141414"/>
|
||||
<path d="M245.838 73.1436L233.846 56.4317H247.745L272.273 90.4301H258.24L252.787 82.825L247.335 90.4301H233.365L245.838 73.1436Z" fill="#141414"/>
|
||||
<path d="M257.931 56.4317H271.765L261.147 71.3177L254.122 61.7786L257.931 56.4317Z" fill="#141414"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 264 B |
@@ -1,5 +1,6 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 334 B |
BIN
public/images/markdown.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/matthew-smith-Rfflri94rs8-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/images/nathan-long-FqaVVi8MkNk-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
public/images/setupguide.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/tengyart-Hcqh_QSO0kg-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
public/images/v16.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/images/v17.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,12 +1,12 @@
|
||||
# llms.txt - Information for AI assistants and LLMs
|
||||
# Learn more: https://llmstxt.org/
|
||||
|
||||
> Real-time markdown blog powered by Convex. All content available as clean markdown.
|
||||
> An open-source markdown sync site you publish from the terminal with npm run sync.
|
||||
|
||||
# Site Information
|
||||
- Name: Markdown Blog
|
||||
- Name: markdown sync site
|
||||
- URL: https://markdowncms.netlify.app
|
||||
- Description: Open source markdown blog with real-time sync, Convex backend, and Netlify deployment.
|
||||
- Description: An open-source markdown sync site you publish from the terminal with npm run sync. Write locally, sync instantly, skip the build, powered by Convex and Netlify.
|
||||
- Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Markdown Blog API
|
||||
title: markdown sync site API
|
||||
description: |
|
||||
API for accessing blog posts and pages as markdown content.
|
||||
All endpoints return JSON by default. Use format=md for raw markdown.
|
||||
@@ -28,7 +28,7 @@ paths:
|
||||
properties:
|
||||
site:
|
||||
type: string
|
||||
example: Markdown Site
|
||||
example: markdown sync site
|
||||
url:
|
||||
type: string
|
||||
example: https://markdowncms.netlify.app
|
||||
|
||||
103
public/raw/about-this-blog.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# About This Markdown Site
|
||||
|
||||
> How this open source site works with Convex for real-time sync and Netlify for deployment.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-01-16
|
||||
Reading time: 4 min read
|
||||
Tags: convex, netlify, open-source, markdown
|
||||
---
|
||||
|
||||
# About This Markdown Site
|
||||
|
||||
This is an open-source markdown sync site built with React, TypeScript, and Convex. Write posts and pages in markdown, sync them to a real-time database, and deploy on Netlify.
|
||||
|
||||
## How It Works
|
||||
|
||||
The architecture is straightforward:
|
||||
|
||||
1. **Markdown files** live in `content/blog/`
|
||||
2. **Convex** stores posts in a real-time database
|
||||
3. **React** renders the frontend
|
||||
4. **Netlify** handles deployment and edge functions
|
||||
|
||||
When you add a new markdown file and run the sync script, your post appears instantly. No rebuild required.
|
||||
|
||||
## The Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| -------- | ------------------------- |
|
||||
| Frontend | React + TypeScript |
|
||||
| Backend | Convex |
|
||||
| Styling | CSS (no framework) |
|
||||
| Hosting | Netlify |
|
||||
| Content | Markdown with frontmatter |
|
||||
|
||||
## Why Convex?
|
||||
|
||||
Convex provides real-time sync out of the box. When you update a post, every connected browser sees the change immediately.
|
||||
|
||||
```typescript
|
||||
// Fetching posts is one line
|
||||
const posts = useQuery(api.posts.getAllPosts);
|
||||
```
|
||||
|
||||
No REST endpoints. No cache invalidation. No WebSocket setup. The data stays in sync automatically.
|
||||
|
||||
## Why Markdown?
|
||||
|
||||
Markdown files in your repo are simpler than a CMS:
|
||||
|
||||
- Version controlled in git (commit, diff, roll back)
|
||||
- Edit with any text editor
|
||||
- AI agents can create and modify posts
|
||||
- No separate login or admin panel
|
||||
|
||||
## Features
|
||||
|
||||
This site includes:
|
||||
|
||||
- **Real-time updates** via Convex subscriptions
|
||||
- **Static pages** for About, Projects, Contact (optional)
|
||||
- **RSS feeds** at `/rss.xml` and `/rss-full.xml`
|
||||
- **Sitemap** at `/sitemap.xml`
|
||||
- **JSON API** at `/api/posts` and `/api/post?slug=xxx`
|
||||
- **Theme switching** between dark, light, tan, and cloud
|
||||
- **SEO optimization** with meta tags and structured data
|
||||
- **AI discovery** via `llms.txt`
|
||||
|
||||
## Fork and Deploy
|
||||
|
||||
The setup takes about 10 minutes:
|
||||
|
||||
1. Fork the repo
|
||||
2. Run `npx convex dev` to set up your backend
|
||||
3. Run `npm run sync` to upload posts (development) or `npm run sync:prod` (production)
|
||||
4. Deploy to Netlify
|
||||
|
||||
**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
|
||||
|
||||
Edit `src/pages/Home.tsx` to change:
|
||||
|
||||
- Site name and description
|
||||
- Featured posts
|
||||
- Footer links
|
||||
|
||||
Edit `src/styles/global.css` to change:
|
||||
|
||||
- Colors and typography
|
||||
- Theme variables
|
||||
- Layout spacing
|
||||
|
||||
## Links
|
||||
|
||||
- [Convex Documentation](https://docs.convex.dev)
|
||||
- [Netlify Documentation](https://docs.netlify.com)
|
||||
- [Setup Guide](/setup-guide)
|
||||
72
public/raw/about.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# About
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-20
|
||||
---
|
||||
|
||||
This is a dev sync site builder for developers who want version-controlled content with real-time delivery. Write markdown locally, sync to a real-time database, and content appears instantly across all connected browsers.
|
||||
|
||||
## What makes it a dev sync system
|
||||
|
||||
**File-based content.** All posts and pages live in `content/blog/` and `content/pages/` as markdown files with frontmatter. No database UI. No admin panel. Just files in your repo.
|
||||
|
||||
**CLI publishing workflow.** Write markdown locally, then run `npm run sync` (dev) or `npm run sync:prod` (production). Content appears instantly via Convex real-time sync.
|
||||
|
||||
**Version controlled.** Markdown source files live in your repo alongside code. Commit changes, review diffs, roll back like any codebase. The sync command pushes content to the database.
|
||||
|
||||
```bash
|
||||
# Edit a post, then commit and sync
|
||||
git add content/blog/my-post.md
|
||||
git commit -m "Update intro paragraph"
|
||||
npm run sync # dev
|
||||
npm run sync:prod # production
|
||||
```
|
||||
|
||||
**No admin interface.** No web UI for creating or editing content. You use your code editor and terminal.
|
||||
|
||||
## The real-time twist
|
||||
|
||||
[](https://convex.dev)
|
||||
|
||||
What separates this from a static site generator is the Convex real-time database. Once you sync content:
|
||||
|
||||
- All connected browsers update immediately
|
||||
- No rebuild or redeploy needed
|
||||
- Search, stats, and RSS update automatically
|
||||
|
||||
It's a hybrid: developer workflow for publishing + real-time delivery like a dynamic CMS.
|
||||
|
||||
## The stack
|
||||
|
||||
| Layer | Technology |
|
||||
| -------- | ------------------ |
|
||||
| Frontend | React + TypeScript |
|
||||
| Backend | Convex |
|
||||
| Styling | CSS variables |
|
||||
| Hosting | Netlify |
|
||||
| Content | Markdown |
|
||||
|
||||
## Features
|
||||
|
||||
- Four theme options (dark, light, tan, cloud)
|
||||
- Mobile menu with hamburger navigation on smaller screens
|
||||
- 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
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
- API endpoints for AI/LLM access
|
||||
- Copy to ChatGPT, Claude, and Perplexity sharing
|
||||
- Generate Skill option for AI agent training
|
||||
- View as Markdown option in share dropdown
|
||||
|
||||
## Who this is for
|
||||
|
||||
- Developers who want version-controlled content
|
||||
- Teams comfortable with markdown and CLI
|
||||
- Projects where AI agents generate content programmatically
|
||||
- Sites that need real-time updates without full rebuilds
|
||||
|
||||
Fork it, customize it, ship it.
|
||||
241
public/raw/changelog.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Changelog
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-20
|
||||
---
|
||||
|
||||
All notable changes to this project.
|
||||
|
||||
## v1.10.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Fork configuration documentation**
|
||||
|
||||
- Added "Files to Update When Forking" section to docs and setup guide
|
||||
- Lists all 9 configuration files users need to update when forking
|
||||
- Includes backend configuration examples for Convex files
|
||||
- Code snippets for `convex/http.ts`, `convex/rss.ts`, `src/pages/Post.tsx`
|
||||
|
||||
**Site branding updates**
|
||||
|
||||
- Updated `public/robots.txt` with sitemap URL and header
|
||||
- Updated `public/llms.txt` with site name and description
|
||||
- Updated `public/.well-known/ai-plugin.json` for AI plugins
|
||||
- Updated `public/openapi.yaml` API title and site name
|
||||
- Updated `convex/http.ts` SITE_URL and SITE_NAME constants
|
||||
|
||||
Same fork documentation added to README.md for discoverability.
|
||||
|
||||
## v1.9.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Scroll-to-top button**
|
||||
|
||||
- Appears after scrolling 300px (configurable)
|
||||
- Uses Phosphor ArrowUp icon for consistency
|
||||
- Smooth scroll animation (configurable)
|
||||
- Works with all four themes (dark, light, tan, cloud)
|
||||
- Enabled by default (can be disabled in Layout.tsx)
|
||||
- Fade-in animation when appearing
|
||||
- Responsive sizing for mobile devices
|
||||
|
||||
New component: `src/components/ScrollToTop.tsx`
|
||||
|
||||
Configuration via `ScrollToTopConfig` interface in `src/components/Layout.tsx`. Uses passive scroll listener for performance.
|
||||
|
||||
## v1.8.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Mobile menu and Generate Skill feature**
|
||||
|
||||
- Mobile menu with hamburger navigation
|
||||
- Slide-out drawer on mobile and tablet views
|
||||
- Accessible with keyboard navigation (Escape to close)
|
||||
- Focus trap for screen reader support
|
||||
- Page links and Home link in drawer
|
||||
- Auto-closes on route change
|
||||
- Generate Skill option in CopyPageDropdown
|
||||
- Formats post/page content as an AI agent skill file
|
||||
- Downloads as `{slug}-skill.md` with skill structure
|
||||
- Includes metadata, when to use, and instructions sections
|
||||
|
||||
New component: `MobileMenu.tsx` with HamburgerButton
|
||||
|
||||
## v1.7.0
|
||||
|
||||
Released December 20, 2025
|
||||
|
||||
**Raw markdown files and CopyPageDropdown improvements**
|
||||
|
||||
- Static raw markdown files at `/raw/{slug}.md`
|
||||
- Generated during `npm run sync` and `npm run sync:prod` in `public/raw/` directory
|
||||
- Each published post and page gets a corresponding static `.md` file
|
||||
- Includes metadata header (type, date, reading time, tags)
|
||||
- View as Markdown option in CopyPageDropdown
|
||||
- Opens raw `.md` file in new tab
|
||||
- Available on all post and page views
|
||||
- Perplexity added to AI service options in CopyPageDropdown
|
||||
- Research articles directly in Perplexity with full content
|
||||
- Featured image support for posts and pages
|
||||
- `image` field displays as square thumbnail in card view
|
||||
- Non-square images automatically cropped to center
|
||||
- Improved markdown table CSS styling
|
||||
- GitHub-style tables with proper borders
|
||||
- Mobile responsive with horizontal scroll
|
||||
- Theme-aware alternating row colors
|
||||
|
||||
New files: `public/raw/*.md` (generated), updated `_redirects`
|
||||
|
||||
## v1.6.1
|
||||
|
||||
Released December 18, 2025
|
||||
|
||||
**Documentation updates**
|
||||
|
||||
- Added AGENTS.md with codebase instructions for AI coding agents
|
||||
- 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
|
||||
43
public/raw/contact.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Contact
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-20
|
||||
---
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
## The technical way
|
||||
|
||||
This site runs on Convex, which means every page view is a live subscription to the database. You are not reading cached HTML. You are reading data that synced moments ago.
|
||||
|
||||
If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required.
|
||||
|
||||
```typescript
|
||||
// A contact form mutation looks like this
|
||||
export const submitContact = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("messages", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## The human way
|
||||
|
||||
Open an issue on GitHub. Or find the author on X. Or send a carrier pigeon. Convex does not support those yet, but the team is probably working on it.
|
||||
|
||||
## Why Convex
|
||||
|
||||
Traditional backends make you write API routes, manage connections, handle caching, and pray nothing breaks at 3am. Convex handles all of that. You write functions. They run in the cloud. Data syncs to clients. Done.
|
||||
|
||||
The contact form example above is the entire backend. No Express. No database drivers. No WebSocket setup. Just a function that inserts a row.
|
||||
|
||||
That is why this site uses Convex.
|
||||
561
public/raw/docs.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# Docs
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-20
|
||||
---
|
||||
|
||||
Reference documentation for setting up, customizing, and deploying this markdown site.
|
||||
|
||||
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/waynesutton/markdown-site.git
|
||||
cd markdown-site
|
||||
npm install
|
||||
npx convex dev
|
||||
npm run sync # development
|
||||
npm run sync:prod # production
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` to view locally.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- Convex account (free at convex.dev)
|
||||
- Netlify account (free at netlify.com)
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
markdown-site/
|
||||
├── content/
|
||||
│ ├── blog/ # Blog posts (.md)
|
||||
│ └── pages/ # Static pages (.md)
|
||||
├── convex/
|
||||
│ ├── schema.ts # Database schema
|
||||
│ ├── posts.ts # Post queries/mutations
|
||||
│ ├── pages.ts # Page queries/mutations
|
||||
│ ├── http.ts # API endpoints
|
||||
│ └── rss.ts # RSS generation
|
||||
├── netlify/
|
||||
│ └── edge-functions/ # Netlify edge functions
|
||||
│ ├── rss.ts # RSS proxy
|
||||
│ ├── sitemap.ts # Sitemap proxy
|
||||
│ ├── api.ts # API proxy
|
||||
│ └── botMeta.ts # OG crawler detection
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ ├── context/ # Theme context
|
||||
│ ├── pages/ # Route components
|
||||
│ └── styles/ # CSS
|
||||
├── public/
|
||||
│ ├── images/ # Static images
|
||||
│ ├── raw/ # Generated raw markdown files
|
||||
│ ├── robots.txt # Crawler rules
|
||||
│ └── llms.txt # AI discovery
|
||||
└── netlify.toml # Deployment config
|
||||
```
|
||||
|
||||
## Content
|
||||
|
||||
### Blog posts
|
||||
|
||||
Create files in `content/blog/` with frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Post Title"
|
||||
description: "SEO description"
|
||||
date: "2025-01-15"
|
||||
slug: "url-path"
|
||||
published: true
|
||||
tags: ["tag1", "tag2"]
|
||||
readTime: "5 min read"
|
||||
image: "/images/og-image.png"
|
||||
---
|
||||
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ------------------------------------ |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
| `slug` | Yes | URL path (unique) |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | OG image and featured card thumbnail |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
|
||||
### Static pages
|
||||
|
||||
Create files in `content/pages/` with frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Page Title"
|
||||
slug: "url-path"
|
||||
published: true
|
||||
order: 1
|
||||
---
|
||||
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ---------------------------------- |
|
||||
| `title` | Yes | Nav link text |
|
||||
| `slug` | Yes | URL path |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `order` | No | Nav order (lower = first) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `image` | No | Thumbnail for featured card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
|
||||
### Syncing content
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run sync
|
||||
|
||||
# Production
|
||||
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 and backend settings
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
|------|----------------|
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site name, URL, description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/openapi.yaml` | Server URL, site name in examples |
|
||||
| `public/.well-known/ai-plugin.json` | Site name, descriptions |
|
||||
|
||||
**Backend constants** (`convex/http.ts` and `convex/rss.ts`):
|
||||
|
||||
```typescript
|
||||
// convex/http.ts
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
|
||||
// convex/rss.ts
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_TITLE = "Your Site Name";
|
||||
const SITE_DESCRIPTION = "Your site description for RSS feeds.";
|
||||
```
|
||||
|
||||
**Post page constants** (`src/pages/Post.tsx`):
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
```
|
||||
|
||||
These constants affect RSS feeds, API responses, sitemaps, and social sharing metadata.
|
||||
|
||||
### Homepage settings
|
||||
|
||||
Edit `src/pages/Home.tsx`:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
name: "Site Name",
|
||||
title: "Tagline",
|
||||
logo: "/images/logo.svg", // null to hide
|
||||
intro: "Introduction text...",
|
||||
bio: "Bio text...",
|
||||
|
||||
// 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",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 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."
|
||||
image: "/images/thumbnail.png"
|
||||
```
|
||||
|
||||
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 |
|
||||
| `image` | Thumbnail for card view (displays as square) |
|
||||
|
||||
**Thumbnail images:** In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to center. Square thumbnails: 400x400px minimum (800x800px for retina).
|
||||
|
||||
**Posts without images:** Cards display without the image area. The card shows just the title and excerpt with adjusted padding.
|
||||
|
||||
**Ordering:** Items with `featuredOrder` appear first (lower numbers first). Items without `featuredOrder` appear after, sorted by creation time.
|
||||
|
||||
**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.
|
||||
|
||||
### Scroll-to-top button
|
||||
|
||||
A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`:
|
||||
|
||||
```typescript
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `threshold` | Pixels scrolled before button appears |
|
||||
| `smooth` | `true` for smooth scroll, `false` for jump |
|
||||
|
||||
Uses Phosphor ArrowUp icon and works with all themes.
|
||||
|
||||
### Theme
|
||||
|
||||
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
|
||||
|
||||
Edit `src/context/ThemeContext.tsx`:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_THEME: Theme = "tan";
|
||||
```
|
||||
|
||||
### Font
|
||||
|
||||
Edit `src/styles/global.css`:
|
||||
|
||||
```css
|
||||
body {
|
||||
/* Sans-serif */
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Serif (default) */
|
||||
font-family: "New York", ui-serif, Georgia, serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Images
|
||||
|
||||
| Image | Location | Size |
|
||||
| ---------------- | ------------------------------ | -------- |
|
||||
| Favicon | `public/favicon.svg` | 512x512 |
|
||||
| Site logo | `public/images/logo.svg` | 512x512 |
|
||||
| Default OG image | `public/images/og-default.svg` | 1200x630 |
|
||||
| Post images | `public/images/` | Any |
|
||||
|
||||
## 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.
|
||||
|
||||
## Mobile menu
|
||||
|
||||
On mobile and tablet screens, a hamburger menu provides navigation. The menu slides out from the left with:
|
||||
|
||||
- Keyboard navigation (Escape to close)
|
||||
- Focus trap for accessibility
|
||||
- Auto-close on route change
|
||||
|
||||
The menu appears automatically on screens under 768px wide.
|
||||
|
||||
## Copy Page dropdown
|
||||
|
||||
Each post and page includes a share dropdown with options:
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with article content |
|
||||
| Open in Claude | Opens Claude with article content |
|
||||
| Open in Perplexity | Opens Perplexity for research with content |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
|
||||
|
||||
**Generate Skill:** Formats the content as an AI agent skill file with metadata, when to use, and instructions sections.
|
||||
|
||||
**Long content:** If content exceeds URL limits, it copies to clipboard and opens the AI service in a new tab. Paste to continue.
|
||||
|
||||
## Real-time stats
|
||||
|
||||
The `/stats` page displays real-time analytics:
|
||||
|
||||
- Active visitors (with per-page breakdown)
|
||||
- Total page views
|
||||
- Unique visitors
|
||||
- Views by page (sorted by count)
|
||||
|
||||
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) |
|
||||
| `/api/export` | All posts with full content |
|
||||
| `/raw/{slug}.md` | Static raw markdown file |
|
||||
| `/.well-known/ai-plugin.json` | AI plugin manifest |
|
||||
| `/openapi.yaml` | OpenAPI 3.0 specification |
|
||||
| `/llms.txt` | AI agent discovery |
|
||||
|
||||
## Raw markdown files
|
||||
|
||||
When you run `npm run sync` (development) or `npm run sync:prod` (production), static `.md` files are generated in `public/raw/` for each published post and page.
|
||||
|
||||
**Access pattern:** `/raw/{slug}.md`
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `/raw/setup-guide.md`
|
||||
- `/raw/about.md`
|
||||
|
||||
These files include a metadata header with type, date, reading time, and tags. Access via the "View as Markdown" option in the Copy Page dropdown.
|
||||
|
||||
## Markdown tables
|
||||
|
||||
Tables render with GitHub-style formatting:
|
||||
|
||||
- Clean borders across all themes
|
||||
- Mobile responsive with horizontal scroll
|
||||
- Theme-aware alternating row colors
|
||||
- Hover states for readability
|
||||
|
||||
Example:
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ------ |
|
||||
| Borders | Clean |
|
||||
| Mobile | Scroll |
|
||||
| Themes | All |
|
||||
|
||||
## 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
|
||||
|
||||
### Netlify setup
|
||||
|
||||
1. Connect GitHub repo to Netlify
|
||||
2. Build command: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'`
|
||||
3. Publish directory: `dist`
|
||||
4. Add env variables:
|
||||
- `CONVEX_DEPLOY_KEY` (from Convex Dashboard > Project Settings > Deploy Key)
|
||||
- `VITE_CONVEX_URL` (your production Convex URL, e.g., `https://your-deployment.convex.cloud`)
|
||||
|
||||
Both are required: deploy key for builds, URL for edge function runtime.
|
||||
|
||||
### Convex production
|
||||
|
||||
```bash
|
||||
npx convex deploy
|
||||
```
|
||||
|
||||
### Edge functions
|
||||
|
||||
RSS, sitemap, and API routes are handled by Netlify Edge Functions in `netlify/edge-functions/`. They dynamically read `VITE_CONVEX_URL` from the environment. No manual URL configuration needed.
|
||||
|
||||
## Convex schema
|
||||
|
||||
```typescript
|
||||
// convex/schema.ts
|
||||
export default defineSchema({
|
||||
posts: defineTable({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
content: v.string(),
|
||||
date: v.string(),
|
||||
published: v.boolean(),
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
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_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()), // For card view
|
||||
image: v.optional(v.string()), // Thumbnail for featured cards
|
||||
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_featured", ["featured"]),
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Posts not appearing**
|
||||
|
||||
- Check `published: true` in frontmatter
|
||||
- Run `npm run sync` for development
|
||||
- Run `npm run sync:prod` for production
|
||||
- Verify in Convex dashboard
|
||||
|
||||
**RSS/Sitemap errors**
|
||||
|
||||
- Verify `VITE_CONVEX_URL` is set in Netlify
|
||||
- Test Convex HTTP URL: `https://your-deployment.convex.site/rss.xml`
|
||||
- Check edge functions in `netlify/edge-functions/`
|
||||
|
||||
**Build failures**
|
||||
|
||||
- Verify `CONVEX_DEPLOY_KEY` is set in Netlify
|
||||
- Ensure `@types/node` is in devDependencies
|
||||
- Build command must include `--include=dev`
|
||||
- Check Node.js version (18+)
|
||||
321
public/raw/how-to-publish.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# How to Publish a Blog Post
|
||||
|
||||
> A quick guide to writing and publishing markdown blog posts using Cursor after your blog is set up.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-01-17
|
||||
Reading time: 3 min read
|
||||
Tags: tutorial, markdown, cursor, publishing
|
||||
---
|
||||
|
||||
# How to Publish a Blog Post
|
||||
|
||||

|
||||
|
||||
Your blog is set up. Now you want to publish. This guide walks through writing a markdown post and syncing it to your live site using Cursor.
|
||||
|
||||
## Create a New Post
|
||||
|
||||
In Cursor, create a new file in `content/blog/`:
|
||||
|
||||
```
|
||||
content/blog/my-new-post.md
|
||||
```
|
||||
|
||||
The filename can be anything. The URL comes from the `slug` field in the frontmatter.
|
||||
|
||||
## Add Frontmatter
|
||||
|
||||
Every post starts with frontmatter between triple dashes:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Your Post Title"
|
||||
description: "A one-sentence summary for SEO and social sharing"
|
||||
date: "2025-01-17"
|
||||
slug: "your-post-url"
|
||||
published: true
|
||||
tags: ["tag1", "tag2"]
|
||||
readTime: "5 min read"
|
||||
---
|
||||
```
|
||||
|
||||
| Field | Required | What It Does |
|
||||
| --------------- | -------- | --------------------------------------------------- |
|
||||
| `title` | Yes | Displays as the post heading |
|
||||
| `description` | Yes | Shows in search results and sharing |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | Becomes the URL path |
|
||||
| `published` | Yes | Set `true` to show, `false` to hide |
|
||||
| `tags` | Yes | Topic labels for the post |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | OG image for social sharing and featured card thumb |
|
||||
| `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
|
||||
|
||||
Below the frontmatter, write your post in markdown:
|
||||
|
||||
```markdown
|
||||
# Your Post Title
|
||||
|
||||
Opening paragraph goes here.
|
||||
|
||||
## First Section
|
||||
|
||||
Content for the first section.
|
||||
|
||||
### Subheading
|
||||
|
||||
More details here.
|
||||
|
||||
- Bullet point one
|
||||
- Bullet point two
|
||||
|
||||
## Code Example
|
||||
|
||||
\`\`\`typescript
|
||||
const greeting = "Hello, world";
|
||||
console.log(greeting);
|
||||
\`\`\`
|
||||
|
||||
## Conclusion
|
||||
|
||||
Wrap up your thoughts.
|
||||
```
|
||||
|
||||
## Sync to Convex
|
||||
|
||||
Open Cursor's terminal and run:
|
||||
|
||||
```bash
|
||||
npm run sync
|
||||
```
|
||||
|
||||
This reads all markdown files in `content/blog/`, parses the frontmatter, and uploads them to your Convex database.
|
||||
|
||||
You should see output like:
|
||||
|
||||
```
|
||||
Syncing posts to Convex...
|
||||
Synced: my-new-post
|
||||
Done! Synced 1 post(s).
|
||||
```
|
||||
|
||||
Your post is now live. No rebuild. No redeploy. The site updates in real time.
|
||||
|
||||
## Publish to Production
|
||||
|
||||
If you have separate dev and prod Convex deployments, sync to production.
|
||||
|
||||
**First-time setup:** Create `.env.production.local` in your project root:
|
||||
|
||||
```
|
||||
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
|
||||
```
|
||||
|
||||
Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment.
|
||||
|
||||
**Sync to production:**
|
||||
|
||||
```bash
|
||||
npm run sync:prod
|
||||
```
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------- |
|
||||
| `.env.local` | Dev deployment (created by `npx convex dev`) |
|
||||
| `.env.production.local` | Prod deployment (create manually) |
|
||||
|
||||
Both files are gitignored.
|
||||
|
||||
## Quick Workflow in Cursor
|
||||
|
||||
Here is the full workflow:
|
||||
|
||||
1. **Create file**: `content/blog/my-post.md`
|
||||
2. **Add frontmatter**: Title, description, date, slug, published, tags
|
||||
3. **Write content**: Markdown with headings, lists, code blocks
|
||||
4. **Sync**: Run `npm run sync` in terminal
|
||||
5. **View**: Open your site and navigate to `/your-slug`
|
||||
|
||||
## Tips
|
||||
|
||||
**Draft posts**: Set `published: false` to save a post without showing it on the site.
|
||||
|
||||
**Update existing posts**: Edit the markdown file and run `npm run sync` again. Changes appear instantly.
|
||||
|
||||
**Delete posts**: Remove the markdown file from `content/blog/` and run sync. The post will be removed from the database.
|
||||
|
||||
**Unique slugs**: Each post needs a unique slug. The sync will fail if two posts share the same slug.
|
||||
|
||||
**Date format**: Use YYYY-MM-DD format for the date field.
|
||||
|
||||
## Adding Images
|
||||
|
||||
Place images in `public/images/` and reference them in your post:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
For the Open Graph image (social sharing), add to frontmatter:
|
||||
|
||||
```yaml
|
||||
image: "/images/my-post-og.png"
|
||||
```
|
||||
|
||||
## Checking Your Post
|
||||
|
||||
After syncing, verify your post:
|
||||
|
||||
1. Open your local dev server: `http://localhost:5173`
|
||||
2. Your post should appear in the post list
|
||||
3. Click through to check formatting
|
||||
4. Test code blocks and images render correctly
|
||||
|
||||
## Adding Static Pages
|
||||
|
||||
You can also create static pages like About, Projects, or Contact. These appear as navigation links in the top right.
|
||||
|
||||
1. Create a file in `content/pages/`:
|
||||
|
||||
```
|
||||
content/pages/about.md
|
||||
```
|
||||
|
||||
2. Add frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "About"
|
||||
slug: "about"
|
||||
published: true
|
||||
order: 1
|
||||
---
|
||||
|
||||
Your page content here...
|
||||
```
|
||||
|
||||
3. Run `npm run sync`
|
||||
|
||||
The page will appear in the navigation. Use `order` to control the display sequence (lower numbers appear first).
|
||||
|
||||
## 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."
|
||||
image: "/images/my-thumbnail.png"
|
||||
```
|
||||
|
||||
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 |
|
||||
| `image` | Thumbnail for card view (displays as square) |
|
||||
|
||||
**Thumbnail images:** In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to fit. Square thumbnails: 400x400px minimum (800x800px for retina).
|
||||
|
||||
## 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).
|
||||
|
||||
## Raw Markdown Files
|
||||
|
||||
When you run `npm run sync` (development) or `npm run sync:prod` (production), the script also generates static `.md` files in `public/raw/`. These are accessible at `/raw/{slug}.md` for any post or page.
|
||||
|
||||
**Example URLs:**
|
||||
|
||||
- `/raw/setup-guide.md`
|
||||
- `/raw/about.md`
|
||||
- `/raw/how-to-publish.md`
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Share raw markdown with AI agents
|
||||
- View the source of any post
|
||||
- Link directly to markdown for LLM ingestion
|
||||
|
||||
The Copy Page dropdown on each post includes a "View as Markdown" option that opens the raw file.
|
||||
|
||||
## Summary
|
||||
|
||||
Publishing is three steps:
|
||||
|
||||
1. Write markdown in `content/blog/` or `content/pages/`
|
||||
2. Run `npm run sync`
|
||||
3. Done
|
||||
|
||||
The Convex database updates immediately. Static raw markdown files are generated. Your site reflects changes in real time. No waiting for builds or deployments.
|
||||
186
public/raw/markdown-with-code-examples.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Writing Markdown with Code Examples
|
||||
|
||||
> A sample post showing how to write markdown with syntax-highlighted code blocks, tables, and more.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-01-17
|
||||
Reading time: 5 min read
|
||||
Tags: markdown, tutorial, code
|
||||
---
|
||||
|
||||
# Writing Markdown with Code Examples
|
||||
|
||||
This post demonstrates how to write markdown content with code blocks, tables, and formatting. Use it as a reference when creating your own posts.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Every post starts with frontmatter between `---` delimiters:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Your Post Title"
|
||||
description: "A brief description for SEO"
|
||||
date: "2025-01-17"
|
||||
slug: "your-url-slug"
|
||||
published: true
|
||||
tags: ["tag1", "tag2"]
|
||||
readTime: "5 min read"
|
||||
---
|
||||
```
|
||||
|
||||
## Code Blocks
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const getPosts = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("posts"),
|
||||
title: v.string(),
|
||||
slug: v.string(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("posts").collect();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### React Component
|
||||
|
||||
```tsx
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../convex/_generated/api";
|
||||
|
||||
export function PostList() {
|
||||
const posts = useQuery(api.posts.getPosts);
|
||||
|
||||
if (posts === undefined) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li key={post._id}>
|
||||
<a href={`/${post.slug}`}>{post.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Bash Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Sync posts to Convex (development)
|
||||
npm run sync
|
||||
|
||||
# Sync posts to Convex (production)
|
||||
npm run sync:prod
|
||||
|
||||
# Deploy to production
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
### JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "markdown-blog",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"sync": "npx ts-node scripts/sync-posts.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inline Code
|
||||
|
||||
Use backticks for inline code like `npm install` or `useQuery`.
|
||||
|
||||
Reference files with inline code: `convex/schema.ts`, `src/pages/Home.tsx`.
|
||||
|
||||
## Tables
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ------------------------------ |
|
||||
| `npm run dev` | Start development server |
|
||||
| `npm run build` | Build for production |
|
||||
| `npm run sync` | Sync markdown to Convex (dev) |
|
||||
| `npm run sync:prod` | Sync markdown to Convex (prod) |
|
||||
| `npx convex dev` | Start Convex dev server |
|
||||
|
||||
## Lists
|
||||
|
||||
### Unordered
|
||||
|
||||
- Write posts in markdown
|
||||
- Store in Convex database
|
||||
- Deploy to Netlify
|
||||
- Updates sync in real-time
|
||||
|
||||
### Ordered
|
||||
|
||||
1. Fork the repository
|
||||
2. Set up Convex backend
|
||||
3. Configure Netlify
|
||||
4. Start writing
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> Markdown files in your repo are simpler than a CMS. Commit changes, review diffs, roll back anytime. AI agents can create posts programmatically. No admin panel needed.
|
||||
|
||||
## Links
|
||||
|
||||
External links open in new tabs: [Convex Docs](https://docs.convex.dev)
|
||||
|
||||
Internal links: [Setup Guide](/setup-guide)
|
||||
|
||||
## Emphasis
|
||||
|
||||
Use **bold** for strong emphasis and _italics_ for lighter emphasis.
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
---
|
||||
|
||||
## Images
|
||||
|
||||
Place images in `public/` and reference them:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
```
|
||||
content/blog/
|
||||
├── about-this-blog.md
|
||||
├── markdown-with-code-examples.md
|
||||
├── setup-guide.md
|
||||
└── your-new-post.md
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. Keep slugs URL-friendly (lowercase, hyphens)
|
||||
2. Set `published: false` for drafts
|
||||
3. Run `npm run sync` after adding posts (or `npm run sync:prod` for production)
|
||||
4. Use descriptive titles for SEO
|
||||
86
public/raw/new-features-search-featured-logos.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# New features: search, featured section, and logo gallery
|
||||
|
||||
> Three updates that make your markdown site more useful: Command+K search, frontmatter-controlled featured items, and a scrolling logo gallery.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-12-17
|
||||
Reading time: 4 min read
|
||||
Tags: features, search, convex, updates
|
||||
---
|
||||
|
||||
## 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.
|
||||
48
public/raw/projects.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Projects
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-20
|
||||
---
|
||||
|
||||
This markdown site is open source and built to be extended. Here is what ships out of the box.
|
||||
|
||||
## Core Features
|
||||
|
||||
**Real-time sync**
|
||||
Posts update instantly across all browsers. No rebuild, no redeploy.
|
||||
|
||||
**Four themes**
|
||||
Dark, light, tan, and cloud. Switch with one click.
|
||||
|
||||
**Markdown authoring**
|
||||
Write in your editor. Frontmatter handles metadata.
|
||||
|
||||
**Static pages**
|
||||
About, Projects, Contact. Add your own.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The site exposes endpoints for search engines and AI agents:
|
||||
|
||||
- `/rss.xml` for RSS readers
|
||||
- `/rss-full.xml` for LLM ingestion
|
||||
- `/sitemap.xml` for search engines
|
||||
- `/api/posts` for JSON access
|
||||
- `/llms.txt` for AI discovery
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
```
|
||||
content/ <- Markdown files
|
||||
blog/ <- Blog posts
|
||||
pages/ <- Static pages
|
||||
convex/ <- Backend functions
|
||||
src/ <- React frontend
|
||||
```
|
||||
|
||||
Convex handles the database, queries, and mutations. The frontend subscribes to data and re-renders when it changes. No REST. No GraphQL. Just reactive functions.
|
||||
|
||||
## Extend It
|
||||
|
||||
Fork the repo. Add features. The codebase is TypeScript end to end with full type safety from database to UI.
|
||||
146
public/raw/raw-markdown-and-copy-improvements.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# v1.7 to v1.10 - Mobile menu, scroll-to-top, and fork configuration
|
||||
|
||||
> New features for mobile navigation, scroll-to-top button, fork configuration documentation, sharing content with AI tools, and improved table styling.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-12-20
|
||||
Reading time: 5 min read
|
||||
Tags: features, markdown, updates, mobile, configuration
|
||||
---
|
||||
|
||||
## Fork configuration (v1.10.0)
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
| ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site name, URL, description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/openapi.yaml` | Server URL, site name in examples |
|
||||
| `public/.well-known/ai-plugin.json` | Site name, descriptions |
|
||||
|
||||
These constants affect RSS feeds, API responses, sitemaps, and social sharing metadata.
|
||||
|
||||
## Scroll-to-top button (v1.9.0)
|
||||
|
||||
A scroll-to-top button now appears after scrolling 300px. Configure it in `src/components/Layout.tsx`:
|
||||
|
||||
```typescript
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
The button uses the Phosphor ArrowUp icon and works with all four themes. It uses a passive scroll listener for performance and includes a fade-in animation.
|
||||
|
||||
## Mobile menu (v1.8.0)
|
||||
|
||||
The site now includes a mobile menu with hamburger navigation for smaller screens. On mobile and tablet views, a hamburger icon appears in the top navigation. Tap it to open a slide-out drawer with all page navigation links.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Smooth CSS transform animations
|
||||
- Keyboard accessible (press Escape to close)
|
||||
- Focus trap for screen reader support
|
||||
- Home link at the bottom of the drawer
|
||||
- Auto-closes when navigating to a new page
|
||||
|
||||
The desktop navigation remains unchanged. The mobile menu only appears on screens below 1024px.
|
||||
|
||||
## Static raw markdown files
|
||||
|
||||
Every published post and page now gets a static `.md` file at `/raw/{slug}.md`. These files are generated automatically when you run `npm run sync`.
|
||||
|
||||
**Example URLs:**
|
||||
|
||||
- `/raw/setup-guide.md`
|
||||
- `/raw/about.md`
|
||||
- `/raw/how-to-publish.md`
|
||||
|
||||
Each file includes a metadata header with type, date, reading time, and tags. The content matches exactly what you see on the page.
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Share raw markdown with AI agents
|
||||
- Link directly to source content for LLM ingestion
|
||||
- View the markdown source of any post
|
||||
|
||||
## View as Markdown in CopyPageDropdown
|
||||
|
||||
The Copy Page dropdown now includes a "View as Markdown" option. Click it to open the raw `.md` file in a new tab.
|
||||
|
||||
This joins the existing options:
|
||||
|
||||
- Copy page (copies formatted markdown to clipboard)
|
||||
- Open in ChatGPT
|
||||
- Open in Claude
|
||||
- Open in Perplexity (new)
|
||||
|
||||
## Perplexity integration
|
||||
|
||||
Perplexity is now available as an AI service option. Click "Open in Perplexity" to send the full article content directly to Perplexity for research and analysis.
|
||||
|
||||
Like the other AI options, if the URL gets too long, the content is copied to your clipboard and Perplexity opens in a new tab. Paste to continue.
|
||||
|
||||
## Featured images
|
||||
|
||||
Posts and pages can now include a featured image that displays in the card view on the homepage.
|
||||
|
||||
Add to your frontmatter:
|
||||
|
||||
```yaml
|
||||
image: "/images/my-thumbnail.png"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
```
|
||||
|
||||
The image displays as a square thumbnail above the title in card view. Non-square images are automatically cropped to center. Recommended size: 400x400px minimum (800x800px for retina).
|
||||
|
||||
## Improved markdown table styling
|
||||
|
||||
Tables now render with GitHub-style formatting across all four themes:
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ----------------------- |
|
||||
| Borders | Clean lines |
|
||||
| Mobile | Horizontal scroll |
|
||||
| Hover | Row highlighting |
|
||||
| Themes | Dark, light, tan, cloud |
|
||||
|
||||
Tables adapt to each theme with proper alternating row colors and hover states.
|
||||
|
||||
## Generate Skill
|
||||
|
||||
The CopyPageDropdown now includes a Generate Skill option. Click it to download the current post or page as an AI agent skill file.
|
||||
|
||||
The skill file includes:
|
||||
|
||||
- Metadata section with title, description, and tags
|
||||
- When to use section describing scenarios for the skill
|
||||
- Instructions section with the full content
|
||||
|
||||
The file downloads as `{slug}-skill.md`. Use these skill files to train AI agents or add context to your workflows.
|
||||
|
||||
## Summary
|
||||
|
||||
These updates improve navigation, configuration, and sharing with AI tools:
|
||||
|
||||
1. **Fork configuration** documentation for all 9 site files
|
||||
2. **Scroll-to-top button** with configurable threshold
|
||||
3. **Mobile menu** with slide-out drawer for smaller screens
|
||||
4. **Raw markdown files** at `/raw/{slug}.md` for direct access
|
||||
5. **View as Markdown** option in CopyPageDropdown
|
||||
6. **Perplexity** added alongside ChatGPT and Claude
|
||||
7. **Generate Skill** for AI agent training
|
||||
8. **Featured images** for visual card layouts
|
||||
9. **Better tables** with responsive styling
|
||||
|
||||
All features work across all four themes and are mobile responsive. Run `npm run sync` for development and `npm run sync:prod` for production to update your site with these changes.
|
||||
904
public/raw/setup-guide.md
Normal file
@@ -0,0 +1,904 @@
|
||||
# Setup Guide - Fork and Deploy Your Own Markdown Site
|
||||
|
||||
> Step-by-step guide to fork this markdown sync site, set up Convex backend, and deploy to Netlify in under 10 minutes.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-01-14
|
||||
Reading time: 8 min read
|
||||
Tags: convex, netlify, tutorial, deployment
|
||||
---
|
||||
|
||||
# Fork and Deploy Your Own Markdown Blog
|
||||
|
||||
This guide walks you through forking [this markdown site](https://github.com/waynesutton/markdown-site), setting up your Convex backend, and deploying to Netlify. The entire process takes about 10 minutes.
|
||||
|
||||
**How publishing works:** Once deployed, you write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so all connected browsers update automatically.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Fork and Deploy Your Own Markdown Blog](#fork-and-deploy-your-own-markdown-blog)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Step 1: Fork the Repository](#step-1-fork-the-repository)
|
||||
- [Step 2: Set Up Convex](#step-2-set-up-convex)
|
||||
- [Create a Convex Project](#create-a-convex-project)
|
||||
- [Verify the Schema](#verify-the-schema)
|
||||
- [Step 3: Sync Your Blog Posts](#step-3-sync-your-blog-posts)
|
||||
- [Step 4: Run Locally](#step-4-run-locally)
|
||||
- [Step 5: Get Your Convex HTTP URL](#step-5-get-your-convex-http-url)
|
||||
- [Step 6: Verify Edge Functions](#step-6-verify-edge-functions)
|
||||
- [Step 7: Deploy to Netlify](#step-7-deploy-to-netlify)
|
||||
- [Option A: Netlify CLI](#option-a-netlify-cli)
|
||||
- [Option B: Netlify Dashboard](#option-b-netlify-dashboard)
|
||||
- [Netlify Build Configuration](#netlify-build-configuration)
|
||||
- [Step 8: Set Up Production Convex](#step-8-set-up-production-convex)
|
||||
- [Writing Blog Posts](#writing-blog-posts)
|
||||
- [Frontmatter Fields](#frontmatter-fields)
|
||||
- [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)
|
||||
- [Files to Update When Forking](#files-to-update-when-forking)
|
||||
- [Update Backend Configuration](#update-backend-configuration)
|
||||
- [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)
|
||||
- [Scroll-to-top button](#scroll-to-top-button)
|
||||
- [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)
|
||||
- [Mobile Navigation](#mobile-navigation)
|
||||
- [Copy Page Dropdown](#copy-page-dropdown)
|
||||
- [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)
|
||||
- [Build failures on Netlify](#build-failures-on-netlify)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Next Steps](#next-steps)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, make sure you have:
|
||||
|
||||
- Node.js 18 or higher installed
|
||||
- A GitHub account
|
||||
- A Convex account (free at [convex.dev](https://convex.dev))
|
||||
- A Netlify account (free at [netlify.com](https://netlify.com))
|
||||
|
||||
## Step 1: Fork the Repository
|
||||
|
||||
Fork the repository to your GitHub account:
|
||||
|
||||
```bash
|
||||
# Clone your forked repo
|
||||
git clone https://github.com/waynesutton/markdown-site.git
|
||||
cd markdown-site
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
## Step 2: Set Up Convex
|
||||
|
||||
Convex is the backend that stores your blog posts and serves the API endpoints.
|
||||
|
||||
### Create a Convex Project
|
||||
|
||||
Run the Convex development command:
|
||||
|
||||
```bash
|
||||
npx convex dev
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Prompt you to log in to Convex (opens browser)
|
||||
2. Ask you to create a new project or select an existing one
|
||||
3. Generate a `.env.local` file with your `VITE_CONVEX_URL`
|
||||
|
||||
Keep this terminal running during development. It syncs your Convex functions automatically.
|
||||
|
||||
### Verify the Schema
|
||||
|
||||
The schema is already defined in `convex/schema.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
posts: defineTable({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
content: v.string(),
|
||||
date: v.string(),
|
||||
published: v.boolean(),
|
||||
tags: v.array(v.string()),
|
||||
readTime: v.optional(v.string()),
|
||||
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_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()),
|
||||
image: 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(),
|
||||
count: v.number(),
|
||||
}).index("by_slug", ["slug"]),
|
||||
});
|
||||
```
|
||||
|
||||
## Step 3: Sync Your Blog Posts
|
||||
|
||||
Blog posts live in `content/blog/` as markdown files. Sync them to Convex:
|
||||
|
||||
```bash
|
||||
npm run sync
|
||||
```
|
||||
|
||||
This reads all markdown files, parses the frontmatter, and uploads them to your Convex database.
|
||||
|
||||
## Step 4: Run Locally
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) to see your blog.
|
||||
|
||||
## Step 5: Get Your Convex HTTP URL
|
||||
|
||||
Your Convex deployment has two URLs:
|
||||
|
||||
- **Client URL**: `https://your-deployment.convex.cloud` (for the React app)
|
||||
- **HTTP URL**: `https://your-deployment.convex.site` (for API endpoints)
|
||||
|
||||
Find your deployment name in the Convex dashboard or check `.env.local`:
|
||||
|
||||
```bash
|
||||
# Your .env.local contains something like:
|
||||
VITE_CONVEX_URL=https://happy-animal-123.convex.cloud
|
||||
```
|
||||
|
||||
The HTTP URL uses `.convex.site` instead of `.convex.cloud`:
|
||||
|
||||
```
|
||||
https://happy-animal-123.convex.site
|
||||
```
|
||||
|
||||
## Step 6: Verify Edge Functions
|
||||
|
||||
The blog uses Netlify Edge Functions to dynamically proxy RSS, sitemap, and API requests to your Convex HTTP endpoints. No manual URL configuration is needed.
|
||||
|
||||
Edge functions in `netlify/edge-functions/`:
|
||||
|
||||
- `rss.ts` - Proxies `/rss.xml` and `/rss-full.xml`
|
||||
- `sitemap.ts` - Proxies `/sitemap.xml`
|
||||
- `api.ts` - Proxies `/api/posts` and `/api/post`
|
||||
- `botMeta.ts` - Serves Open Graph HTML to social media crawlers
|
||||
|
||||
These functions automatically read `VITE_CONVEX_URL` from your environment and convert it to the Convex HTTP site URL (`.cloud` becomes `.site`).
|
||||
|
||||
## Step 7: Deploy to Netlify
|
||||
|
||||
For detailed Convex + Netlify integration, see the official [Convex Netlify Deployment Guide](https://docs.convex.dev/production/hosting/netlify).
|
||||
|
||||
### Option A: Netlify CLI
|
||||
|
||||
```bash
|
||||
# Install Netlify CLI
|
||||
npm install -g netlify-cli
|
||||
|
||||
# Login to Netlify
|
||||
netlify login
|
||||
|
||||
# Initialize site
|
||||
netlify init
|
||||
|
||||
# Deploy
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
### Option B: Netlify Dashboard
|
||||
|
||||
1. Go to [app.netlify.com](https://app.netlify.com)
|
||||
2. Click "Add new site" then "wImport an existing project"
|
||||
3. Connect your GitHub repository
|
||||
4. Configure build settings:
|
||||
- Build command: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'`
|
||||
- Publish directory: `dist`
|
||||
5. Add environment variables:
|
||||
- `CONVEX_DEPLOY_KEY`: Generate from [Convex Dashboard](https://dashboard.convex.dev) > Project Settings > Deploy Key
|
||||
- `VITE_CONVEX_URL`: Your production Convex URL (e.g., `https://your-deployment.convex.cloud`)
|
||||
6. Click "Deploy site"
|
||||
|
||||
The `CONVEX_DEPLOY_KEY` deploys functions at build time. The `VITE_CONVEX_URL` is required for edge functions to proxy RSS, sitemap, and API requests at runtime.
|
||||
|
||||
### Netlify Build Configuration
|
||||
|
||||
The `netlify.toml` file includes the correct build settings:
|
||||
|
||||
```toml
|
||||
[build]
|
||||
command = "npm ci --include=dev && npx convex deploy --cmd 'npm run build'"
|
||||
publish = "dist"
|
||||
|
||||
[build.environment]
|
||||
NODE_VERSION = "20"
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `npm ci --include=dev` forces devDependencies to install even when `NODE_ENV=production`
|
||||
- The build script uses `npx vite build` to resolve vite from node_modules
|
||||
- `@types/node` is required for TypeScript to recognize `process.env`
|
||||
|
||||
## Step 8: Set Up Production Convex
|
||||
|
||||
For production, deploy your Convex functions:
|
||||
|
||||
```bash
|
||||
npx convex deploy
|
||||
```
|
||||
|
||||
This creates a production deployment. Update your Netlify environment variable with the production URL if different.
|
||||
|
||||
## Writing Blog Posts
|
||||
|
||||
Create new posts in `content/blog/`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Your Post Title"
|
||||
description: "A brief description for SEO and social sharing"
|
||||
date: "2025-01-15"
|
||||
slug: "your-post-url"
|
||||
published: true
|
||||
tags: ["tag1", "tag2"]
|
||||
readTime: "5 min read"
|
||||
image: "/images/my-post-image.png"
|
||||
---
|
||||
|
||||
Your markdown content here...
|
||||
```
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| `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
|
||||
|
||||
Place images in `public/images/` and reference them in your posts:
|
||||
|
||||
**Header/OG Image (in frontmatter):**
|
||||
|
||||
```yaml
|
||||
image: "/images/my-header.png"
|
||||
```
|
||||
|
||||
This image appears when sharing on social media. Recommended: 1200x630 pixels.
|
||||
|
||||
**Inline Images (in content):**
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
**External Images:**
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### Sync After Adding Posts
|
||||
|
||||
After adding or editing posts, sync to Convex.
|
||||
|
||||
**Development sync:**
|
||||
|
||||
```bash
|
||||
npm run sync
|
||||
```
|
||||
|
||||
**Production sync:**
|
||||
|
||||
First, create `.env.production.local` in your project root:
|
||||
|
||||
```
|
||||
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
|
||||
```
|
||||
|
||||
Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment.
|
||||
|
||||
Then sync:
|
||||
|
||||
```bash
|
||||
npm run sync:prod
|
||||
```
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Purpose | Created by |
|
||||
| ----------------------- | ------------------- | ---------------------------- |
|
||||
| `.env.local` | Dev deployment URL | `npx convex dev` (automatic) |
|
||||
| `.env.production.local` | Prod deployment URL | You (manual) |
|
||||
|
||||
Both files are gitignored. Each developer creates their own local environment files.
|
||||
|
||||
### 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
|
||||
|
||||
### Files to Update When Forking
|
||||
|
||||
When you fork this project, update these files with your site information:
|
||||
|
||||
| File | What to update |
|
||||
| ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `src/pages/Home.tsx` | Site name, title, intro, bio, featured config, logo gallery |
|
||||
| `convex/http.ts` | `SITE_URL`, `SITE_NAME` (API responses, sitemap) |
|
||||
| `convex/rss.ts` | `SITE_URL`, `SITE_TITLE`, `SITE_DESCRIPTION` (RSS feeds) |
|
||||
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME`, `DEFAULT_OG_IMAGE` (OG tags) |
|
||||
| `index.html` | Title, meta description, OG tags, JSON-LD |
|
||||
| `public/llms.txt` | Site name, URL, description |
|
||||
| `public/robots.txt` | Sitemap URL |
|
||||
| `public/openapi.yaml` | Server URL, site name in examples |
|
||||
| `public/.well-known/ai-plugin.json` | Site name, descriptions |
|
||||
|
||||
### Update Backend Configuration
|
||||
|
||||
These constants affect RSS feeds, API responses, sitemaps, and social sharing metadata.
|
||||
|
||||
**convex/http.ts:**
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
```
|
||||
|
||||
**convex/rss.ts:**
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_TITLE = "Your Site Name";
|
||||
const SITE_DESCRIPTION = "Your site description for RSS feeds.";
|
||||
```
|
||||
|
||||
**src/pages/Post.tsx:**
|
||||
|
||||
```typescript
|
||||
const SITE_URL = "https://your-site.netlify.app";
|
||||
const SITE_NAME = "Your Site Name";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
```
|
||||
|
||||
### Change the Favicon
|
||||
|
||||
Replace `public/favicon.svg` with your own SVG icon. The default is a rounded square with the letter "m":
|
||||
|
||||
```xml
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="#000000"/>
|
||||
<text x="256" y="330" text-anchor="middle" font-size="300" font-weight="800" fill="#ffffff">m</text>
|
||||
</svg>
|
||||
```
|
||||
|
||||
To use a different letter or icon, edit the SVG directly or replace the file.
|
||||
|
||||
### Change the Site Logo
|
||||
|
||||
The logo appears on the homepage. Edit `src/pages/Home.tsx`:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
logo: "/images/logo.svg", // Set to null to hide the logo
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Replace `public/images/logo.svg` with your own logo file. Recommended: SVG format, 512x512 pixels.
|
||||
|
||||
### Change the Default Open Graph Image
|
||||
|
||||
The default OG image is used when a post does not have an `image` field in its frontmatter. Replace `public/images/og-default.svg` with your own image.
|
||||
|
||||
Recommended dimensions: 1200x630 pixels. Supported formats: PNG, JPG, or SVG.
|
||||
|
||||
Update the reference in `src/pages/Post.tsx`:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
```
|
||||
|
||||
### Update Site Configuration
|
||||
|
||||
Edit `src/pages/Home.tsx` to customize:
|
||||
|
||||
```typescript
|
||||
const siteConfig = {
|
||||
name: "Your Name",
|
||||
title: "Your Title",
|
||||
intro: "Your introduction...",
|
||||
bio: "Your bio...",
|
||||
|
||||
// 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: {
|
||||
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."
|
||||
image: "/images/my-thumbnail.png"
|
||||
```
|
||||
|
||||
Then run `npm run sync`. The post 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 |
|
||||
| `image` | Thumbnail for card view (displays as square) |
|
||||
|
||||
**Thumbnail images:** In card view, the `image` field displays as a square thumbnail above the title. Non-square images are automatically cropped to center. Square thumbnails: 400x400px minimum (800x800px for retina).
|
||||
|
||||
**Posts without images:** Cards display without the image area. The card shows just the title and excerpt with adjusted padding.
|
||||
|
||||
**Order featured items:**
|
||||
|
||||
Use `featuredOrder` to control display order. Lower numbers appear first. Posts and pages are sorted together. Items without `featuredOrder` appear after numbered items, sorted by creation time.
|
||||
|
||||
**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.
|
||||
|
||||
### Scroll-to-top button
|
||||
|
||||
A scroll-to-top button appears after scrolling down on posts and pages. Configure it in `src/components/Layout.tsx`:
|
||||
|
||||
```typescript
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `threshold` | Pixels scrolled before button appears |
|
||||
| `smooth` | `true` for smooth scroll, `false` for jump |
|
||||
|
||||
The button uses Phosphor ArrowUp icon and works with all four themes. It uses a passive scroll listener for performance.
|
||||
|
||||
### Change the Default Theme
|
||||
|
||||
Edit `src/context/ThemeContext.tsx`:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_THEME: Theme = "tan"; // Options: "dark", "light", "tan", "cloud"
|
||||
```
|
||||
|
||||
### Change the Font
|
||||
|
||||
The blog uses a serif font by default. To switch to sans-serif, edit `src/styles/global.css`:
|
||||
|
||||
```css
|
||||
body {
|
||||
/* Sans-serif */
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Serif (default) */
|
||||
font-family:
|
||||
"New York",
|
||||
-apple-system-ui-serif,
|
||||
ui-serif,
|
||||
Georgia,
|
||||
serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Add Static Pages (Optional)
|
||||
|
||||
Create optional pages like About, Projects, or Contact. These appear as navigation links in the top right corner.
|
||||
|
||||
1. Create a `content/pages/` directory
|
||||
2. Add markdown files with frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "About"
|
||||
slug: "about"
|
||||
published: true
|
||||
order: 1
|
||||
---
|
||||
|
||||
Your page content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| ----------- | -------- | ----------------------------- |
|
||||
| `title` | Yes | Page title (shown in nav) |
|
||||
| `slug` | Yes | URL path (e.g., `/about`) |
|
||||
| `published` | Yes | Set `true` to show |
|
||||
| `order` | No | Display order (lower = first) |
|
||||
|
||||
3. Run `npm run sync` to sync pages
|
||||
|
||||
Pages appear automatically in the navigation when published.
|
||||
|
||||
### Update SEO Meta Tags
|
||||
|
||||
Edit `index.html` to update:
|
||||
|
||||
- Site title
|
||||
- Meta description
|
||||
- Open Graph tags
|
||||
- JSON-LD structured data
|
||||
|
||||
### Update llms.txt and robots.txt
|
||||
|
||||
Edit `public/llms.txt` and `public/robots.txt` with your site information.
|
||||
|
||||
## 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`:
|
||||
|
||||
- **Active visitors**: See who is currently on your site and which pages they are viewing
|
||||
- **Total page views**: All-time view count across the site
|
||||
- **Unique visitors**: Count based on anonymous session IDs
|
||||
- **Views by page**: Every page and post ranked by view count
|
||||
|
||||
Stats update automatically without refreshing. Powered by Convex subscriptions.
|
||||
|
||||
How it works:
|
||||
|
||||
- Page views are recorded as event records (not counters) to prevent write conflicts
|
||||
- Active sessions use a heartbeat system (30 second interval)
|
||||
- Sessions expire after 2 minutes of inactivity
|
||||
- A cron job cleans up stale sessions every 5 minutes
|
||||
- No personal data is stored (only anonymous UUIDs)
|
||||
|
||||
## Mobile Navigation
|
||||
|
||||
On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route.
|
||||
|
||||
## Copy Page Dropdown
|
||||
|
||||
Each post and page includes a share dropdown with options for AI tools:
|
||||
|
||||
| Option | Description |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with article content |
|
||||
| Open in Claude | Opens Claude with article content |
|
||||
| Open in Perplexity | Opens Perplexity for research with content |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Generate Skill | Downloads `{slug}-skill.md` for AI agent training |
|
||||
|
||||
**Generate Skill** formats the content as an AI agent skill file with metadata, when to use, and instructions sections.
|
||||
|
||||
**Long content:** If content exceeds URL limits, it copies to clipboard and opens the AI service in a new tab. Paste to continue.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Your blog includes these API endpoints for search engines and AI:
|
||||
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | --------------------------- |
|
||||
| `/stats` | Real-time site analytics |
|
||||
| `/rss.xml` | RSS feed with descriptions |
|
||||
| `/rss-full.xml` | RSS feed with full content |
|
||||
| `/sitemap.xml` | Dynamic XML sitemap |
|
||||
| `/api/posts` | JSON list of all posts |
|
||||
| `/api/post?slug=xxx` | Single post as JSON |
|
||||
| `/api/post?slug=xxx&format=md` | Single post as raw markdown |
|
||||
| `/api/export` | Batch export all posts |
|
||||
| `/raw/{slug}.md` | Static raw markdown file |
|
||||
| `/.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
|
||||
|
||||
### Posts not appearing
|
||||
|
||||
1. Check that `published: true` in frontmatter
|
||||
2. Run `npm run sync` to sync posts to development
|
||||
3. Run `npm run sync:prod` to sync posts to production
|
||||
4. Verify posts exist in Convex dashboard
|
||||
|
||||
### RSS/Sitemap not working
|
||||
|
||||
1. Verify `VITE_CONVEX_URL` is set in Netlify environment variables
|
||||
2. Check that Convex HTTP endpoints are deployed (`npx convex deploy`)
|
||||
3. Test the Convex HTTP URL directly: `https://your-deployment.convex.site/rss.xml`
|
||||
4. Verify edge functions exist in `netlify/edge-functions/`
|
||||
|
||||
### Build failures on Netlify
|
||||
|
||||
Common errors and fixes:
|
||||
|
||||
**"vite: not found" or "Cannot find package 'vite'"**
|
||||
|
||||
Netlify sets `NODE_ENV=production` which skips devDependencies. Fix by using `npm ci --include=dev` in your build command:
|
||||
|
||||
```toml
|
||||
[build]
|
||||
command = "npm ci --include=dev && npx convex deploy --cmd 'npm run build'"
|
||||
```
|
||||
|
||||
Also ensure your build script uses `npx`:
|
||||
|
||||
```json
|
||||
"build": "npx vite build"
|
||||
```
|
||||
|
||||
**"Cannot find name 'process'"**
|
||||
|
||||
Add `@types/node` to devDependencies:
|
||||
|
||||
```bash
|
||||
npm install --save-dev @types/node
|
||||
```
|
||||
|
||||
**General checklist:**
|
||||
|
||||
1. Verify `CONVEX_DEPLOY_KEY` environment variable is set in Netlify
|
||||
2. Check that `@types/node` is in devDependencies
|
||||
3. Ensure Node.js version is 20 or higher
|
||||
4. Verify build command includes `--include=dev`
|
||||
|
||||
See [netlify-deploy-fix.md](https://github.com/waynesutton/markdown-site/blob/main/netlify-deploy-fix.md) for detailed troubleshooting.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
markdown-site/
|
||||
├── content/
|
||||
│ ├── blog/ # Markdown blog posts
|
||||
│ └── pages/ # Static pages (About, Docs, etc.)
|
||||
├── convex/ # Convex backend functions
|
||||
│ ├── http.ts # HTTP endpoints
|
||||
│ ├── posts.ts # Post queries/mutations
|
||||
│ ├── pages.ts # Page queries/mutations
|
||||
│ ├── rss.ts # RSS feed generation
|
||||
│ ├── stats.ts # Analytics functions
|
||||
│ └── schema.ts # Database schema
|
||||
├── netlify/
|
||||
│ └── edge-functions/ # Netlify edge functions
|
||||
│ ├── rss.ts # RSS proxy
|
||||
│ ├── sitemap.ts # Sitemap proxy
|
||||
│ ├── api.ts # API proxy
|
||||
│ └── botMeta.ts # OG crawler detection
|
||||
├── public/
|
||||
│ ├── images/ # Static images
|
||||
│ ├── raw/ # Generated raw markdown files
|
||||
│ ├── robots.txt # Crawler rules
|
||||
│ └── llms.txt # AI agent discovery
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ ├── context/ # Theme context
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── pages/ # Page components
|
||||
│ └── styles/ # Global CSS
|
||||
├── netlify.toml # Netlify configuration
|
||||
└── package.json # Dependencies
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After deploying:
|
||||
|
||||
1. Add your own blog posts
|
||||
2. Customize the theme colors in `global.css`
|
||||
3. Update the featured essays list
|
||||
4. Submit your sitemap to Google Search Console
|
||||
5. Share your first post
|
||||
|
||||
Your blog is now live with real-time updates, SEO optimization, and AI-friendly APIs. Every time you sync new posts, they appear immediately without redeploying.
|
||||
119
public/raw/using-images-in-posts.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Using Images in Blog Posts
|
||||
|
||||
> Learn how to add header images, inline images, and Open Graph images to your markdown blog posts.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-01-18
|
||||
Reading time: 4 min read
|
||||
Tags: images, tutorial, markdown, open-graph
|
||||
---
|
||||
|
||||
# Using Images in Blog Posts
|
||||
|
||||
This post demonstrates how to add images to your blog posts. You can use header images for social sharing, inline images for content, and set Open Graph images for better link previews.
|
||||
|
||||
## Header/Open Graph Images
|
||||
|
||||
The `image` field in your frontmatter serves two purposes:
|
||||
|
||||
1. **Open Graph image** for social media previews (Twitter, LinkedIn, Slack)
|
||||
2. **Thumbnail image** for featured section card view on the homepage
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Your Post Title"
|
||||
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
|
||||
---
|
||||
```
|
||||
|
||||
**Recommended dimensions:** 1200x630 pixels (1.91:1 ratio) for social sharing
|
||||
|
||||
## Featured Section Thumbnails
|
||||
|
||||
When a post or page is marked as `featured: true`, the `image` field displays as a square thumbnail in the card view.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "Featured Post"
|
||||
image: "/images/thumbnail.png"
|
||||
featured: true
|
||||
featuredOrder: 1
|
||||
---
|
||||
```
|
||||
|
||||
**Square display:** Non-square images are automatically cropped to fit the square thumbnail area. The crop centers on the middle of the image. For best results, use images where the main subject is centered.
|
||||
|
||||
**Square thumbnails:** 400x400px minimum (800x800px for retina)
|
||||
|
||||
## Inline Images
|
||||
|
||||
Add images anywhere in your markdown content using standard syntax:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Here's an example image from Unsplash:
|
||||
|
||||

|
||||
|
||||
The alt text appears as a caption below the image.
|
||||
|
||||
## Image Sources
|
||||
|
||||
You can use images from:
|
||||
|
||||
| Source | Example |
|
||||
| ----------- | --------------------------------- |
|
||||
| Local files | `/images/my-image.png` |
|
||||
| Unsplash | `https://images.unsplash.com/...` |
|
||||
| Cloudinary | `https://res.cloudinary.com/...` |
|
||||
| Any CDN | Full URL to image |
|
||||
|
||||
### Local Images
|
||||
|
||||
Place image files in the `public/images/` directory:
|
||||
|
||||
```
|
||||
public/
|
||||
images/
|
||||
screenshot.png
|
||||
diagram.svg
|
||||
photo.jpg
|
||||
```
|
||||
|
||||
Reference them with a leading slash:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### External Images
|
||||
|
||||
Use the full URL for images hosted elsewhere:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Here's a coding-themed image:
|
||||
|
||||

|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use descriptive alt text** for accessibility
|
||||
2. **Optimize image size** before uploading (compress PNG/JPG)
|
||||
3. **Use CDN URLs** for external images when possible
|
||||
4. **Match OG image dimensions** to 1200x630 for social previews
|
||||
5. **Use SVG** for logos and icons
|
||||
|
||||
## Free Image Resources
|
||||
|
||||
These sites offer free, high-quality images:
|
||||
|
||||
- [Unsplash](https://unsplash.com) - Photos
|
||||
- [Pexels](https://pexels.com) - Photos and videos
|
||||
- [unDraw](https://undraw.co) - Illustrations
|
||||
- [Heroicons](https://heroicons.com) - Icons
|
||||
@@ -1,11 +1,11 @@
|
||||
# robots.txt for Wayne Sutton's Blog
|
||||
# robots.txt for markdown sync site
|
||||
# https://www.robotstxt.org/
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://your-blog.netlify.app/sitemap.xml
|
||||
Sitemap: https://markdowncms.netlify.app/sitemap.xml
|
||||
|
||||
# AI and LLM crawlers
|
||||
User-agent: GPTBot
|
||||
|
||||
@@ -20,6 +20,7 @@ dotenv.config();
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), "content", "blog");
|
||||
const PAGES_DIR = path.join(process.cwd(), "content", "pages");
|
||||
const RAW_OUTPUT_DIR = path.join(process.cwd(), "public", "raw");
|
||||
|
||||
interface PostFrontmatter {
|
||||
title: string;
|
||||
@@ -57,6 +58,7 @@ interface PageFrontmatter {
|
||||
published: boolean;
|
||||
order?: number; // Display order in navigation
|
||||
excerpt?: string; // Short excerpt for card view
|
||||
image?: string; // Thumbnail/OG image URL for featured cards
|
||||
featured?: boolean; // Show in featured section
|
||||
featuredOrder?: number; // Order in featured section (lower = first)
|
||||
}
|
||||
@@ -68,6 +70,7 @@ interface ParsedPage {
|
||||
published: boolean;
|
||||
order?: number;
|
||||
excerpt?: string; // Short excerpt for card view
|
||||
image?: string; // Thumbnail/OG image URL for featured cards
|
||||
featured?: boolean; // Show in featured section
|
||||
featuredOrder?: number; // Order in featured section (lower = first)
|
||||
}
|
||||
@@ -151,6 +154,7 @@ function parsePageFile(filePath: string): ParsedPage | null {
|
||||
published: frontmatter.published ?? true,
|
||||
order: frontmatter.order,
|
||||
excerpt: frontmatter.excerpt, // Short excerpt for card view
|
||||
image: frontmatter.image, // Thumbnail/OG image URL for featured cards
|
||||
featured: frontmatter.featured, // Show in featured section
|
||||
featuredOrder: frontmatter.featuredOrder, // Order in featured section
|
||||
};
|
||||
@@ -227,10 +231,11 @@ async function syncPosts() {
|
||||
|
||||
// Sync pages if pages directory exists
|
||||
const pageFiles = getAllPageFiles();
|
||||
const pages: ParsedPage[] = [];
|
||||
|
||||
if (pageFiles.length > 0) {
|
||||
console.log(`\nFound ${pageFiles.length} page files\n`);
|
||||
|
||||
const pages: ParsedPage[] = [];
|
||||
for (const filePath of pageFiles) {
|
||||
const page = parsePageFile(filePath);
|
||||
if (page) {
|
||||
@@ -256,6 +261,9 @@ async function syncPosts() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate static raw markdown files in public/raw/
|
||||
generateRawMarkdownFiles(posts, pages);
|
||||
}
|
||||
|
||||
// Create a sample post if none exist
|
||||
@@ -303,5 +311,99 @@ More posts coming soon. Thanks for reading!
|
||||
console.log(`Created sample post: ${filePath}`);
|
||||
}
|
||||
|
||||
// Generate static markdown file in public/raw/ directory
|
||||
function generateRawMarkdownFile(
|
||||
slug: string,
|
||||
title: string,
|
||||
description: string,
|
||||
content: string,
|
||||
date: string,
|
||||
tags: string[],
|
||||
readTime?: string,
|
||||
type: "post" | "page" = "post"
|
||||
): void {
|
||||
// Ensure raw output directory exists
|
||||
if (!fs.existsSync(RAW_OUTPUT_DIR)) {
|
||||
fs.mkdirSync(RAW_OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Build metadata section
|
||||
const metadataLines: string[] = [];
|
||||
metadataLines.push(`Type: ${type}`);
|
||||
metadataLines.push(`Date: ${date}`);
|
||||
if (readTime) metadataLines.push(`Reading time: ${readTime}`);
|
||||
if (tags && tags.length > 0) metadataLines.push(`Tags: ${tags.join(", ")}`);
|
||||
|
||||
// Build the full markdown document
|
||||
let markdown = `# ${title}\n\n`;
|
||||
|
||||
// Add description if available
|
||||
if (description) {
|
||||
markdown += `> ${description}\n\n`;
|
||||
}
|
||||
|
||||
// Add metadata block
|
||||
markdown += `---\n${metadataLines.join("\n")}\n---\n\n`;
|
||||
|
||||
// Add main content
|
||||
markdown += content;
|
||||
|
||||
// Write to file
|
||||
const filePath = path.join(RAW_OUTPUT_DIR, `${slug}.md`);
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
}
|
||||
|
||||
// Generate all raw markdown files during sync
|
||||
function generateRawMarkdownFiles(
|
||||
posts: ParsedPost[],
|
||||
pages: ParsedPage[]
|
||||
): void {
|
||||
console.log("\nGenerating static markdown files in public/raw/...");
|
||||
|
||||
// Clear existing raw files
|
||||
if (fs.existsSync(RAW_OUTPUT_DIR)) {
|
||||
const existingFiles = fs.readdirSync(RAW_OUTPUT_DIR);
|
||||
for (const file of existingFiles) {
|
||||
if (file.endsWith(".md")) {
|
||||
fs.unlinkSync(path.join(RAW_OUTPUT_DIR, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate files for published posts
|
||||
const publishedPosts = posts.filter((p) => p.published);
|
||||
for (const post of publishedPosts) {
|
||||
generateRawMarkdownFile(
|
||||
post.slug,
|
||||
post.title,
|
||||
post.description,
|
||||
post.content,
|
||||
post.date,
|
||||
post.tags,
|
||||
post.readTime,
|
||||
"post"
|
||||
);
|
||||
}
|
||||
|
||||
// Generate files for published pages
|
||||
const publishedPages = pages.filter((p) => p.published);
|
||||
for (const page of publishedPages) {
|
||||
generateRawMarkdownFile(
|
||||
page.slug,
|
||||
page.title,
|
||||
"", // pages don't have description
|
||||
page.content,
|
||||
new Date().toISOString().split("T")[0], // pages don't have date
|
||||
[], // pages don't have tags
|
||||
undefined,
|
||||
"page"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Generated ${publishedPosts.length} post files and ${publishedPages.length} page files`
|
||||
);
|
||||
}
|
||||
|
||||
// Run the sync
|
||||
syncPosts().catch(console.error);
|
||||
|
||||
@@ -404,6 +404,29 @@ export default function BlogPost({ content }: BlogPostProps) {
|
||||
hr() {
|
||||
return <hr className="blog-hr" />;
|
||||
},
|
||||
// Table components for GitHub-style tables
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="blog-table-wrapper">
|
||||
<table className="blog-table">{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="blog-thead">{children}</thead>;
|
||||
},
|
||||
tbody({ children }) {
|
||||
return <tbody className="blog-tbody">{children}</tbody>;
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr className="blog-tr">{children}</tr>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="blog-th">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="blog-td">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,25 +1,143 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Copy, MessageSquare, Sparkles } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Copy, MessageSquare, Sparkles, Search, Check, AlertCircle, FileText, Download } from "lucide-react";
|
||||
|
||||
// Maximum URL length for query parameters (conservative limit)
|
||||
const MAX_URL_LENGTH = 6000;
|
||||
|
||||
// AI service configurations
|
||||
interface AIService {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: typeof Copy;
|
||||
baseUrl: string;
|
||||
description: string;
|
||||
supportsUrlPrefill: boolean;
|
||||
// Custom URL builder for services with special formats
|
||||
buildUrl?: (prompt: string) => string;
|
||||
}
|
||||
|
||||
// All services send the full markdown content directly
|
||||
const AI_SERVICES: AIService[] = [
|
||||
{
|
||||
id: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
icon: MessageSquare,
|
||||
baseUrl: "https://chatgpt.com/",
|
||||
description: "Analyze with ChatGPT",
|
||||
supportsUrlPrefill: true,
|
||||
// ChatGPT accepts ?q= with full text content
|
||||
buildUrl: (prompt) => `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`,
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude",
|
||||
icon: Sparkles,
|
||||
baseUrl: "https://claude.ai/new",
|
||||
description: "Analyze with Claude",
|
||||
supportsUrlPrefill: true,
|
||||
buildUrl: (prompt) => `https://claude.ai/new?q=${encodeURIComponent(prompt)}`,
|
||||
},
|
||||
{
|
||||
id: "perplexity",
|
||||
name: "Perplexity",
|
||||
icon: Search,
|
||||
baseUrl: "https://www.perplexity.ai/search",
|
||||
description: "Research with Perplexity",
|
||||
supportsUrlPrefill: true,
|
||||
buildUrl: (prompt) => `https://www.perplexity.ai/search?q=${encodeURIComponent(prompt)}`,
|
||||
},
|
||||
];
|
||||
|
||||
// Extended props interface with optional metadata
|
||||
interface CopyPageDropdownProps {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
date?: string;
|
||||
tags?: string[];
|
||||
readTime?: string;
|
||||
}
|
||||
|
||||
// Converts the blog post to markdown format for LLMs
|
||||
function formatAsMarkdown(title: string, content: string, url: string): string {
|
||||
return `# ${title}\n\nSource: ${url}\n\n${content}`;
|
||||
// Enhanced markdown format for better LLM parsing
|
||||
function formatAsMarkdown(props: CopyPageDropdownProps): string {
|
||||
const { title, content, url, description, date, tags, readTime } = props;
|
||||
|
||||
// Build metadata section
|
||||
const metadataLines: string[] = [];
|
||||
metadataLines.push(`Source: ${url}`);
|
||||
if (date) metadataLines.push(`Date: ${date}`);
|
||||
if (readTime) metadataLines.push(`Reading time: ${readTime}`);
|
||||
if (tags && tags.length > 0) metadataLines.push(`Tags: ${tags.join(", ")}`);
|
||||
|
||||
// Build the full markdown document
|
||||
let markdown = `# ${title}\n\n`;
|
||||
|
||||
// Add description if available
|
||||
if (description) {
|
||||
markdown += `> ${description}\n\n`;
|
||||
}
|
||||
|
||||
export default function CopyPageDropdown({
|
||||
title,
|
||||
content,
|
||||
url,
|
||||
}: CopyPageDropdownProps) {
|
||||
// Add metadata block
|
||||
markdown += `---\n${metadataLines.join("\n")}\n---\n\n`;
|
||||
|
||||
// Add main content
|
||||
markdown += content;
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
// Format content as an Agent Skill file for AI agents
|
||||
function formatAsSkill(props: CopyPageDropdownProps): string {
|
||||
const { title, content, url, description, tags } = props;
|
||||
|
||||
const generatedDate = new Date().toISOString().split("T")[0];
|
||||
const tagList = tags && tags.length > 0 ? tags.join(", ") : "none";
|
||||
|
||||
let skill = `# ${title}\n\n`;
|
||||
skill += `## Metadata\n`;
|
||||
skill += `- Source: ${url}\n`;
|
||||
skill += `- Tags: ${tagList}\n`;
|
||||
skill += `- Generated: ${generatedDate}\n\n`;
|
||||
|
||||
if (description) {
|
||||
skill += `## When to use this skill\n`;
|
||||
skill += `${description}\n\n`;
|
||||
}
|
||||
|
||||
skill += `## Instructions\n`;
|
||||
skill += content;
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
// Check if URL length exceeds safe limits
|
||||
function isUrlTooLong(url: string): boolean {
|
||||
return url.length > MAX_URL_LENGTH;
|
||||
}
|
||||
|
||||
// Feedback state type
|
||||
type FeedbackState = "idle" | "copied" | "error" | "url-too-long";
|
||||
|
||||
export default function CopyPageDropdown(props: CopyPageDropdownProps) {
|
||||
const { title } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState>("idle");
|
||||
const [feedbackMessage, setFeedbackMessage] = useState("");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Clear feedback after delay
|
||||
const clearFeedback = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setFeedback("idle");
|
||||
setFeedbackMessage("");
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -35,47 +153,203 @@ export default function CopyPageDropdown({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Handle copy page action
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen || !menuRef.current) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
|
||||
const items = menu.querySelectorAll<HTMLButtonElement>(".copy-page-item");
|
||||
const currentIndex = Array.from(items).findIndex(
|
||||
(item) => item === document.activeElement
|
||||
);
|
||||
|
||||
switch (event.key) {
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
if (currentIndex < items.length - 1) {
|
||||
items[currentIndex + 1].focus();
|
||||
} else {
|
||||
items[0].focus();
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
items[currentIndex - 1].focus();
|
||||
} else {
|
||||
items[items.length - 1].focus();
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
items[0]?.focus();
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
items[items.length - 1]?.focus();
|
||||
break;
|
||||
case "Tab":
|
||||
// Close dropdown on tab out
|
||||
if (!event.shiftKey && currentIndex === items.length - 1) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus first item when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && firstItemRef.current) {
|
||||
// Small delay to ensure menu is rendered
|
||||
setTimeout(() => firstItemRef.current?.focus(), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Safe clipboard write with error handling
|
||||
const writeToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Fallback for older browsers or permission issues
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return true;
|
||||
} catch {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle copy page action with error handling
|
||||
const handleCopyPage = async () => {
|
||||
const markdown = formatAsMarkdown(title, content, url);
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
setIsOpen(false);
|
||||
}, 1500);
|
||||
const markdown = formatAsMarkdown(props);
|
||||
const success = await writeToClipboard(markdown);
|
||||
|
||||
if (success) {
|
||||
setFeedback("copied");
|
||||
setFeedbackMessage("Copied!");
|
||||
} else {
|
||||
setFeedback("error");
|
||||
setFeedbackMessage("Failed to copy");
|
||||
}
|
||||
|
||||
clearFeedback();
|
||||
setTimeout(() => setIsOpen(false), 1500);
|
||||
};
|
||||
|
||||
// Open in ChatGPT with the page content
|
||||
const handleOpenInChatGPT = () => {
|
||||
const markdown = formatAsMarkdown(title, content, url);
|
||||
const encodedText = encodeURIComponent(
|
||||
`Please analyze this article:\n\n${markdown}`,
|
||||
);
|
||||
window.open(`https://chat.openai.com/?q=${encodedText}`, "_blank");
|
||||
// Generic handler for opening AI services
|
||||
// All services receive the full markdown content directly
|
||||
const handleOpenInAI = async (service: AIService) => {
|
||||
const markdown = formatAsMarkdown(props);
|
||||
const prompt = `Please analyze this article:\n\n${markdown}`;
|
||||
|
||||
// Build the target URL using the service's buildUrl function
|
||||
if (!service.buildUrl) {
|
||||
// Fallback: copy to clipboard and open base URL
|
||||
const success = await writeToClipboard(markdown);
|
||||
if (success) {
|
||||
setFeedback("url-too-long");
|
||||
setFeedbackMessage("Copied! Paste in " + service.name);
|
||||
window.open(service.baseUrl, "_blank");
|
||||
} else {
|
||||
setFeedback("error");
|
||||
setFeedbackMessage("Failed to copy content");
|
||||
}
|
||||
clearFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = service.buildUrl(prompt);
|
||||
|
||||
// Check URL length - if too long, copy to clipboard instead
|
||||
if (isUrlTooLong(targetUrl)) {
|
||||
const success = await writeToClipboard(markdown);
|
||||
if (success) {
|
||||
setFeedback("url-too-long");
|
||||
setFeedbackMessage("Copied! Paste in " + service.name);
|
||||
window.open(service.baseUrl, "_blank");
|
||||
} else {
|
||||
setFeedback("error");
|
||||
setFeedbackMessage("Failed to copy content");
|
||||
}
|
||||
clearFeedback();
|
||||
} else {
|
||||
// URL is within limits, open directly with prefilled content
|
||||
window.open(targetUrl, "_blank");
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open in Claude with the page content
|
||||
const handleOpenInClaude = () => {
|
||||
const markdown = formatAsMarkdown(title, content, url);
|
||||
const encodedText = encodeURIComponent(
|
||||
`Please analyze this article:\n\n${markdown}`,
|
||||
);
|
||||
window.open(`https://claude.ai/new?q=${encodedText}`, "_blank");
|
||||
setIsOpen(false);
|
||||
// Handle download skill file
|
||||
const handleDownloadSkill = () => {
|
||||
const skillContent = formatAsSkill(props);
|
||||
const blob = new Blob([skillContent], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create temporary link and trigger download
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${props.slug}-skill.md`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up object URL
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setFeedback("copied");
|
||||
setFeedbackMessage("Downloaded!");
|
||||
clearFeedback();
|
||||
setTimeout(() => setIsOpen(false), 1500);
|
||||
};
|
||||
|
||||
// Get feedback icon
|
||||
const getFeedbackIcon = () => {
|
||||
switch (feedback) {
|
||||
case "copied":
|
||||
return <Check size={16} className="copy-page-icon feedback-success" />;
|
||||
case "error":
|
||||
return <AlertCircle size={16} className="copy-page-icon feedback-error" />;
|
||||
case "url-too-long":
|
||||
return <Check size={16} className="copy-page-icon feedback-warning" />;
|
||||
default:
|
||||
return <Copy size={16} className="copy-page-icon" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="copy-page-dropdown" ref={dropdownRef}>
|
||||
{/* Trigger button */}
|
||||
{/* Trigger button with ARIA attributes */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="copy-page-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-haspopup="menu"
|
||||
aria-controls="copy-page-menu"
|
||||
aria-label={`Copy or share: ${title}`}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<Copy size={14} aria-hidden="true" />
|
||||
<span>Copy page</span>
|
||||
<svg
|
||||
className={`dropdown-chevron ${isOpen ? "open" : ""}`}
|
||||
@@ -84,6 +358,7 @@ export default function CopyPageDropdown({
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2.5 4L5 6.5L7.5 4"
|
||||
@@ -95,46 +370,95 @@ export default function CopyPageDropdown({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{/* Dropdown menu with ARIA role */}
|
||||
{isOpen && (
|
||||
<div className="copy-page-menu">
|
||||
<div
|
||||
ref={menuRef}
|
||||
id="copy-page-menu"
|
||||
className="copy-page-menu"
|
||||
role="menu"
|
||||
aria-label="Copy and share options"
|
||||
>
|
||||
{/* Copy page option */}
|
||||
<button className="copy-page-item" onClick={handleCopyPage}>
|
||||
<Copy size={16} className="copy-page-icon" />
|
||||
<button
|
||||
ref={firstItemRef}
|
||||
className="copy-page-item"
|
||||
onClick={handleCopyPage}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
{getFeedbackIcon()}
|
||||
<div className="copy-page-item-content">
|
||||
<span className="copy-page-item-title">
|
||||
{copied ? "Copied!" : "Copy page"}
|
||||
{feedback !== "idle" ? feedbackMessage : "Copy page"}
|
||||
</span>
|
||||
<span className="copy-page-item-desc">
|
||||
Copy page as Markdown for LLMs
|
||||
Copy as Markdown for LLMs
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Open in ChatGPT */}
|
||||
<button className="copy-page-item" onClick={handleOpenInChatGPT}>
|
||||
<MessageSquare size={16} className="copy-page-icon" />
|
||||
{/* AI service options */}
|
||||
{AI_SERVICES.map((service) => {
|
||||
const Icon = service.icon;
|
||||
return (
|
||||
<button
|
||||
key={service.id}
|
||||
className="copy-page-item"
|
||||
onClick={() => handleOpenInAI(service)}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon size={16} className="copy-page-icon" aria-hidden="true" />
|
||||
<div className="copy-page-item-content">
|
||||
<span className="copy-page-item-title">
|
||||
Open in ChatGPT
|
||||
<span className="external-arrow">↗</span>
|
||||
Open in {service.name}
|
||||
<span className="external-arrow" aria-hidden="true">↗</span>
|
||||
</span>
|
||||
<span className="copy-page-item-desc">
|
||||
Ask questions about this page
|
||||
{service.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* View as Markdown option */}
|
||||
<button
|
||||
className="copy-page-item"
|
||||
onClick={() => {
|
||||
window.open(`/raw/${props.slug}.md`, "_blank");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
<FileText size={16} className="copy-page-icon" aria-hidden="true" />
|
||||
<div className="copy-page-item-content">
|
||||
<span className="copy-page-item-title">
|
||||
View as Markdown
|
||||
<span className="external-arrow" aria-hidden="true">↗</span>
|
||||
</span>
|
||||
<span className="copy-page-item-desc">
|
||||
Open raw .md file
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Open in Claude */}
|
||||
<button className="copy-page-item" onClick={handleOpenInClaude}>
|
||||
<Sparkles size={16} className="copy-page-icon" />
|
||||
{/* Generate Skill option */}
|
||||
<button
|
||||
className="copy-page-item"
|
||||
onClick={handleDownloadSkill}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Download size={16} className="copy-page-icon" aria-hidden="true" />
|
||||
<div className="copy-page-item-content">
|
||||
<span className="copy-page-item-title">
|
||||
Open in Claude
|
||||
<span className="external-arrow">↗</span>
|
||||
Generate Skill
|
||||
</span>
|
||||
<span className="copy-page-item-desc">
|
||||
Ask questions about this page
|
||||
Download as AI agent skill
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface FeaturedData {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
image?: string; // Thumbnail image for card view
|
||||
type: "post" | "page";
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ export default function FeaturedCards({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt || p.description,
|
||||
image: p.image,
|
||||
type: "post" as const,
|
||||
featuredOrder: p.featuredOrder,
|
||||
})),
|
||||
@@ -57,13 +59,21 @@ export default function FeaturedCards({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt || "",
|
||||
image: p.image,
|
||||
type: "page" as const,
|
||||
featuredOrder: p.featuredOrder,
|
||||
})),
|
||||
];
|
||||
|
||||
// Sort by featuredOrder (lower first)
|
||||
// Sort: items with images first, then by featuredOrder within each group
|
||||
return combined.sort((a, b) => {
|
||||
// Primary sort: items with images come first
|
||||
const hasImageA = a.image ? 0 : 1;
|
||||
const hasImageB = b.image ? 0 : 1;
|
||||
if (hasImageA !== hasImageB) {
|
||||
return hasImageA - hasImageB;
|
||||
}
|
||||
// Secondary sort: by featuredOrder (lower first)
|
||||
const orderA = a.featuredOrder ?? 999;
|
||||
const orderB = b.featuredOrder ?? 999;
|
||||
return orderA - orderB;
|
||||
@@ -85,6 +95,7 @@ export default function FeaturedCards({
|
||||
result.push({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt || post.description,
|
||||
image: post.image,
|
||||
slug: post.slug,
|
||||
type: "post",
|
||||
});
|
||||
@@ -96,6 +107,7 @@ export default function FeaturedCards({
|
||||
result.push({
|
||||
title: page.title,
|
||||
excerpt: page.excerpt || "",
|
||||
image: page.image,
|
||||
slug: page.slug,
|
||||
type: "page",
|
||||
});
|
||||
@@ -131,10 +143,23 @@ export default function FeaturedCards({
|
||||
<div className="featured-cards">
|
||||
{featuredData.map((item) => (
|
||||
<a key={item.slug} href={`/${item.slug}`} className="featured-card">
|
||||
{/* Thumbnail image displayed as square using object-fit: cover */}
|
||||
{item.image && (
|
||||
<div className="featured-card-image-wrapper">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="featured-card-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="featured-card-content">
|
||||
<h3 className="featured-card-title">{item.title}</h3>
|
||||
{item.excerpt && (
|
||||
<p className="featured-card-excerpt">{item.excerpt}</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ReactNode, useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import SearchModal from "./SearchModal";
|
||||
import MobileMenu, { HamburgerButton } from "./MobileMenu";
|
||||
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
|
||||
|
||||
// Scroll-to-top configuration - enabled by default
|
||||
// Customize threshold (pixels) to control when button appears
|
||||
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -14,6 +24,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
// Fetch published pages for navigation
|
||||
const pages = useQuery(api.pages.getAllPages);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Open search modal
|
||||
const openSearch = useCallback(() => {
|
||||
@@ -25,6 +37,20 @@ export default function Layout({ children }: LayoutProps) {
|
||||
setIsSearchOpen(false);
|
||||
}, []);
|
||||
|
||||
// Mobile menu handlers
|
||||
const openMobileMenu = useCallback(() => {
|
||||
setIsMobileMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeMobileMenu = useCallback(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Handle Command+K / Ctrl+K keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -47,9 +73,17 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="layout">
|
||||
{/* Top navigation bar with page links, search, and theme toggle */}
|
||||
<div className="top-nav">
|
||||
{/* Page navigation links (optional pages like About, Projects, Contact) */}
|
||||
{/* Hamburger button for mobile menu (visible on mobile/tablet only) */}
|
||||
<div className="mobile-menu-trigger">
|
||||
<HamburgerButton
|
||||
onClick={openMobileMenu}
|
||||
isOpen={isMobileMenuOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page navigation links (visible on desktop only) */}
|
||||
{pages && pages.length > 0 && (
|
||||
<nav className="page-nav">
|
||||
<nav className="page-nav desktop-only">
|
||||
{pages.map((page) => (
|
||||
<Link
|
||||
key={page.slug}
|
||||
@@ -76,10 +110,32 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu drawer */}
|
||||
<MobileMenu isOpen={isMobileMenuOpen} onClose={closeMobileMenu}>
|
||||
{/* Page navigation links in mobile menu */}
|
||||
{pages && pages.length > 0 && (
|
||||
<nav className="mobile-nav-links">
|
||||
{pages.map((page) => (
|
||||
<Link
|
||||
key={page.slug}
|
||||
to={`/${page.slug}`}
|
||||
className="mobile-nav-link"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</MobileMenu>
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
|
||||
{/* Search modal */}
|
||||
<SearchModal isOpen={isSearchOpen} onClose={closeSearch} />
|
||||
|
||||
{/* Scroll to top button */}
|
||||
<ScrollToTop config={scrollToTopConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
|
||||
{logo.href ? (
|
||||
<a
|
||||
href={logo.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={logo.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={logo.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="logo-marquee-link"
|
||||
>
|
||||
<img
|
||||
|
||||
144
src/components/MobileMenu.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface MobileMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile menu drawer component
|
||||
* Opens from the left side on mobile/tablet views
|
||||
* Uses CSS transforms for smooth 60fps animations
|
||||
*/
|
||||
export default function MobileMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: MobileMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle escape key to close menu
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
// Prevent body scroll when menu is open
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Focus trap - keep focus within menu when open
|
||||
useEffect(() => {
|
||||
if (isOpen && menuRef.current) {
|
||||
const firstFocusable = menuRef.current.querySelector<HTMLElement>(
|
||||
'button, a, input, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle backdrop click
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay */}
|
||||
<div
|
||||
className={`mobile-menu-backdrop ${isOpen ? "open" : ""}`}
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`mobile-menu-drawer ${isOpen ? "open" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Site navigation"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="mobile-menu-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Menu content */}
|
||||
<div className="mobile-menu-content">{children}</div>
|
||||
|
||||
{/* Home link at bottom */}
|
||||
<div className="mobile-menu-footer">
|
||||
<Link to="/" className="mobile-menu-home-link" onClick={onClose}>
|
||||
Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamburger button component for opening the mobile menu
|
||||
*/
|
||||
interface HamburgerButtonProps {
|
||||
onClick: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function HamburgerButton({ onClick, isOpen }: HamburgerButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className="hamburger-button"
|
||||
onClick={onClick}
|
||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
81
src/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ArrowUp } from "@phosphor-icons/react";
|
||||
|
||||
// Scroll-to-top configuration
|
||||
export interface ScrollToTopConfig {
|
||||
enabled: boolean; // Show/hide the button
|
||||
threshold: number; // Pixels scrolled before button appears
|
||||
smooth: boolean; // Use smooth scrolling animation
|
||||
}
|
||||
|
||||
// Default configuration - enabled by default
|
||||
export const defaultScrollToTopConfig: ScrollToTopConfig = {
|
||||
enabled: true,
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true,
|
||||
};
|
||||
|
||||
interface ScrollToTopProps {
|
||||
config?: Partial<ScrollToTopConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll-to-top button component
|
||||
* Appears after user scrolls past threshold
|
||||
* Uses Phosphor ArrowUp icon and theme-aware styling
|
||||
*/
|
||||
export default function ScrollToTop({ config }: ScrollToTopProps) {
|
||||
// Merge provided config with defaults
|
||||
const mergedConfig: ScrollToTopConfig = {
|
||||
...defaultScrollToTopConfig,
|
||||
...config,
|
||||
};
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check scroll position and update visibility
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
setIsVisible(scrollY > mergedConfig.threshold);
|
||||
}, [mergedConfig.threshold]);
|
||||
|
||||
// Set up scroll listener
|
||||
useEffect(() => {
|
||||
if (!mergedConfig.enabled) return;
|
||||
|
||||
// Check initial position
|
||||
checkScrollPosition();
|
||||
|
||||
// Add scroll listener with passive flag for performance
|
||||
window.addEventListener("scroll", checkScrollPosition, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", checkScrollPosition);
|
||||
};
|
||||
}, [mergedConfig.enabled, checkScrollPosition]);
|
||||
|
||||
// Scroll to top handler
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: mergedConfig.smooth ? "smooth" : "auto",
|
||||
});
|
||||
};
|
||||
|
||||
// Don't render if disabled or not visible
|
||||
if (!mergedConfig.enabled || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="scroll-to-top"
|
||||
onClick={scrollToTop}
|
||||
aria-label="Scroll to top"
|
||||
title="Scroll to top"
|
||||
>
|
||||
<ArrowUp size={20} weight="bold" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { api } from "../../convex/_generated/api";
|
||||
// Heartbeat interval: 30 seconds
|
||||
const HEARTBEAT_INTERVAL_MS = 30 * 1000;
|
||||
|
||||
// Minimum time between heartbeats to prevent write conflicts: 5 seconds
|
||||
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
|
||||
// Minimum time between heartbeats to prevent write conflicts: 10 seconds (matches backend dedup window)
|
||||
const HEARTBEAT_DEBOUNCE_MS = 10 * 1000;
|
||||
|
||||
// Session ID key in localStorage
|
||||
const SESSION_ID_KEY = "markdown_blog_session_id";
|
||||
|
||||
@@ -13,12 +13,13 @@ import LogoMarquee, {
|
||||
const siteConfig = {
|
||||
// Basic site info
|
||||
name: 'markdown "sync" site',
|
||||
title: "Real-time Site with Convex",
|
||||
title: "markdown sync site",
|
||||
// Optional logo/header image (place in public/images/, set to null to hide)
|
||||
logo: "/images/logo.svg" as string | null,
|
||||
intro: (
|
||||
<>
|
||||
An open source markdown blog powered by Convex and deployed on Netlify.{" "}
|
||||
An open-source markdown "sync" site you publish from the terminal with npm
|
||||
run sync.{" "}
|
||||
<a
|
||||
href="https://github.com/waynesutton/markdown-site"
|
||||
target="_blank"
|
||||
@@ -30,11 +31,11 @@ const siteConfig = {
|
||||
, customize it, ship it.
|
||||
</>
|
||||
),
|
||||
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.`,
|
||||
bio: `Write locally, sync instantly, skip the build. Powered by Convex and Netlify.`,
|
||||
|
||||
// Featured section configuration
|
||||
// viewMode: 'list' shows bullet list, 'cards' shows card grid with excerpts
|
||||
featuredViewMode: "list" as "cards" | "list",
|
||||
featuredViewMode: "cards" as "cards" | "list",
|
||||
// Allow users to toggle between list and card views
|
||||
showViewToggle: true,
|
||||
|
||||
@@ -50,8 +51,8 @@ const siteConfig = {
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-2.svg",
|
||||
href: "https://markdowncms.netlify.app/",
|
||||
src: "/images/logos/convex-wordmark-black.svg",
|
||||
href: "/about#the-real-time-twist",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/sample-logo-3.svg",
|
||||
@@ -68,7 +69,7 @@ const siteConfig = {
|
||||
] 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)
|
||||
title: "Trusted by (sample logos)", // Optional title above the marquee (set to undefined to hide)
|
||||
} as LogoGalleryConfig,
|
||||
|
||||
// Links for footer section
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useParams, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import BlogPost from "../components/BlogPost";
|
||||
@@ -9,17 +9,35 @@ import { useState, useEffect } from "react";
|
||||
|
||||
// Site configuration
|
||||
const SITE_URL = "https://markdowncms.netlify.app";
|
||||
const SITE_NAME = "Markdown Site";
|
||||
const SITE_NAME = "markdown sync site";
|
||||
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
|
||||
|
||||
export default function Post() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// Check for page first, then post
|
||||
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
|
||||
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Scroll to hash anchor after content loads
|
||||
useEffect(() => {
|
||||
if (!location.hash) return;
|
||||
if (page === undefined && post === undefined) return;
|
||||
|
||||
// Small delay to ensure content is rendered
|
||||
const timer = setTimeout(() => {
|
||||
const id = location.hash.slice(1);
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [location.hash, page, post]);
|
||||
|
||||
// Update page title for static pages
|
||||
useEffect(() => {
|
||||
if (!page) return;
|
||||
@@ -137,6 +155,8 @@ export default function Post() {
|
||||
title={page.title}
|
||||
content={page.content}
|
||||
url={window.location.href}
|
||||
slug={page.slug}
|
||||
description={page.excerpt}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
@@ -191,11 +211,16 @@ export default function Post() {
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
{/* Copy page dropdown for sharing */}
|
||||
{/* Copy page dropdown for sharing with full metadata */}
|
||||
<CopyPageDropdown
|
||||
title={post.title}
|
||||
content={post.content}
|
||||
url={window.location.href}
|
||||
slug={post.slug}
|
||||
description={post.description}
|
||||
date={post.date}
|
||||
tags={post.tags}
|
||||
readTime={post.readTime}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -133,12 +133,16 @@ body {
|
||||
/* Top navigation bar */
|
||||
.top-nav {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
top: 10px;
|
||||
right: 13px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
/* Themed background to prevent content overlap on scroll */
|
||||
background-color: var(--bg-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Page navigation links (About, Projects, Contact, etc.) */
|
||||
@@ -475,6 +479,19 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Feedback states for copy/share actions */
|
||||
.copy-page-icon.feedback-success {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.copy-page-icon.feedback-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.copy-page-icon.feedback-warning {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.copy-page-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -650,6 +667,67 @@ body {
|
||||
margin: 48px 0;
|
||||
}
|
||||
|
||||
/* Table styles - GitHub-style tables */
|
||||
.blog-table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin: 24px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.blog-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.blog-thead {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.blog-th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.blog-td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Alternating row colors */
|
||||
.blog-tbody .blog-tr:nth-child(even) {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.blog-tbody .blog-tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Inline code within tables */
|
||||
.blog-td code,
|
||||
.blog-th code {
|
||||
background-color: var(--inline-code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family:
|
||||
SF Mono,
|
||||
Monaco,
|
||||
Cascadia Code,
|
||||
Roboto Mono,
|
||||
Consolas,
|
||||
monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Code styles */
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
@@ -841,13 +919,14 @@ body {
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 24px 16px;
|
||||
padding: 40px 16px 16px 24px;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
top: 16px;
|
||||
top: 6px;
|
||||
right: 16px;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
@@ -904,6 +983,16 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Table mobile styles */
|
||||
.blog-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.blog-th,
|
||||
.blog-td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Copy page dropdown mobile */
|
||||
.copy-page-menu {
|
||||
width: 260px;
|
||||
@@ -1556,13 +1645,14 @@ body {
|
||||
}
|
||||
|
||||
.featured-card {
|
||||
display: block;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-card:hover {
|
||||
@@ -1570,6 +1660,38 @@ body {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Thumbnail image wrapper with square aspect ratio */
|
||||
.featured-card-image-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Image displays as square regardless of original aspect ratio */
|
||||
.featured-card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.featured-card:hover .featured-card-image {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* Content wrapper for text below image */
|
||||
.featured-card-content {
|
||||
padding: 16px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Cards without images get padding directly */
|
||||
.featured-card:not(:has(.featured-card-image-wrapper)) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.featured-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
@@ -1722,10 +1844,14 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-card {
|
||||
.featured-card:not(:has(.featured-card-image-wrapper)) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.featured-card-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.featured-card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
@@ -1750,6 +1876,11 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* On mobile single column, use smaller square aspect ratio */
|
||||
.featured-card-image-wrapper {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.view-toggle-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -1764,3 +1895,320 @@ body {
|
||||
max-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
MOBILE MENU STYLES
|
||||
Left-side drawer for mobile/tablet navigation
|
||||
=========================================== */
|
||||
|
||||
/* Hide hamburger on desktop, show on mobile/tablet */
|
||||
.mobile-menu-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show desktop nav by default */
|
||||
.desktop-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-trigger {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hamburger button */
|
||||
.hamburger-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hamburger-button:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Mobile menu backdrop */
|
||||
.mobile-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
visibility 0.25s ease;
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.mobile-menu-backdrop.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Mobile menu drawer */
|
||||
.mobile-menu-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
background-color: var(--bg-primary);
|
||||
z-index: 999;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mobile-menu-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.mobile-menu-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-close:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Menu content area */
|
||||
.mobile-menu-content {
|
||||
flex: 1;
|
||||
padding: 60px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Mobile navigation links */
|
||||
.mobile-nav-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-nav-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Menu footer with home link */
|
||||
.mobile-menu-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-menu-home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-home-link:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Theme-specific adjustments */
|
||||
:root[data-theme="dark"] .mobile-menu-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .mobile-menu-drawer {
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .mobile-menu-backdrop {
|
||||
background-color: rgba(139, 115, 85, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .mobile-menu-drawer {
|
||||
box-shadow: 4px 0 24px rgba(139, 115, 85, 0.15);
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .mobile-menu-backdrop {
|
||||
background-color: rgba(100, 116, 139, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .mobile-menu-drawer {
|
||||
box-shadow: 4px 0 24px rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
|
||||
/* Tablet adjustments */
|
||||
@media (min-width: 481px) and (max-width: 768px) {
|
||||
.mobile-menu-drawer {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.mobile-menu-drawer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mobile-menu-content {
|
||||
padding: 56px 20px 20px;
|
||||
}
|
||||
|
||||
.mobile-menu-footer {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop - hide mobile menu components */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-menu-trigger,
|
||||
.mobile-menu-backdrop,
|
||||
.mobile-menu-drawer,
|
||||
.hamburger-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Scroll to Top Button ===== */
|
||||
.scroll-to-top {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
animation: scrollToTopFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes scrollToTopFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-to-top:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.scroll-to-top:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Theme-specific shadows */
|
||||
:root[data-theme="dark"] .scroll-to-top {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .scroll-to-top:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .scroll-to-top {
|
||||
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="tan"] .scroll-to-top:hover {
|
||||
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.15);
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .scroll-to-top {
|
||||
box-shadow: 0 2px 8px rgba(100, 116, 139, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="cloud"] .scroll-to-top:hover {
|
||||
box-shadow: 0 4px 12px rgba(100, 116, 139, 0.15);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.scroll-to-top {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.scroll-to-top {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||