From 5a8df466810ed627b248bd0a66dfd76bc082f6ab Mon Sep 17 00:00:00 2001 From: Wayne Sutton Date: Mon, 5 Jan 2026 18:30:48 -0800 Subject: [PATCH] feat: Add semantic search with vector embeddings Add vector-based semantic search to complement keyword search. Users can toggle between "Keyword" and "Semantic" modes in the search modal (Cmd+K, then Tab to switch). Semantic search: - Uses OpenAI text-embedding-ada-002 (1536 dimensions) - Finds content by meaning, not exact words - Shows similarity scores as percentages - ~300ms latency, ~$0.0001/query - Graceful fallback if OPENAI_API_KEY not set New files: - convex/embeddings.ts - Embedding generation actions - convex/embeddingsQueries.ts - Queries/mutations for embeddings - convex/semanticSearch.ts - Vector search action - convex/semanticSearchQueries.ts - Result hydration queries - content/pages/docs-search.md - Keyword search docs - content/pages/docs-semantic-search.md - Semantic search docs Changes: - convex/schema.ts: Add embedding field and by_embedding vectorIndex - SearchModal.tsx: Add mode toggle (TextAa/Brain icons) - sync-posts.ts: Generate embeddings after content sync - global.css: Search mode toggle styles Documentation updated: - changelog.md, TASK.md, files.md, about.md, home.md Configuration: npx convex env set OPENAI_API_KEY sk-your-key Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Status: Ready to commit. All semantic search files are staged. The TypeScript warnings are pre-existing (unused variables) and don't affect the build. --- .claude/plans/librarian-integration.md | 156 +++ .claude/skills/dev.md | 1 + AGENTS.md | 2 +- CLAUDE.md | 2 +- TASK.md | 42 +- changelog.md | 75 ++ .../netlify-edge-excludedpath-ai-crawlers.md | 39 +- content/blog/setup-guide.md | 48 +- .../team-workflows-git-version-control.md | 2 +- content/pages/about.md | 4 +- content/pages/changelog-page.md | 111 ++ content/pages/docs-configuration.md | 444 ++++++ content/pages/docs-content.md | 307 +++++ content/pages/docs-dashboard.md | 400 ++++++ content/pages/docs-deployment.md | 106 ++ content/pages/docs-frontmatter.md | 121 ++ content/pages/docs-search.md | 232 ++++ content/pages/docs-semantic-search.md | 134 ++ content/pages/docs.md | 1193 +--------------- content/pages/home.md | 2 + convex/_generated/api.d.ts | 12 + convex/cms.ts | 412 ++++++ convex/embeddings.ts | 161 +++ convex/embeddingsQueries.ts | 105 ++ convex/importAction.ts | 145 ++ convex/pages.ts | 24 +- convex/posts.ts | 24 +- convex/schema.ts | 16 + convex/semanticSearch.ts | 156 +++ convex/semanticSearchQueries.ts | 62 + files.md | 16 +- package-lock.json | 368 ++++- package.json | 11 +- public/llms.txt | 2 +- public/raw/about.md | 6 +- public/raw/changelog.md | 132 +- public/raw/contact.md | 2 +- public/raw/docs-configuration.md | 436 ++++++ public/raw/docs-content.md | 299 ++++ public/raw/docs-dashboard.md | 392 ++++++ public/raw/docs-deployment.md | 98 ++ public/raw/docs-frontmatter.md | 113 ++ public/raw/docs-search.md | 224 +++ public/raw/docs-semantic-search.md | 126 ++ public/raw/documentation.md | 1197 +---------------- public/raw/footer.md | 2 +- public/raw/home-intro.md | 6 +- public/raw/index.md | 21 +- .../netlify-edge-excludedpath-ai-crawlers.md | 39 +- public/raw/newsletter.md | 2 +- public/raw/projects.md | 2 +- public/raw/setup-guide.md | 48 +- .../raw/team-workflows-git-version-control.md | 2 +- scripts/export-db-posts.ts | 262 ++++ scripts/sync-posts.ts | 18 + src/components/SearchModal.tsx | 183 ++- src/pages/Dashboard.tsx | 679 +++++++++- src/styles/global.css | 327 ++++- 58 files changed, 7024 insertions(+), 2527 deletions(-) create mode 100644 .claude/plans/librarian-integration.md create mode 100644 content/pages/docs-configuration.md create mode 100644 content/pages/docs-content.md create mode 100644 content/pages/docs-dashboard.md create mode 100644 content/pages/docs-deployment.md create mode 100644 content/pages/docs-frontmatter.md create mode 100644 content/pages/docs-search.md create mode 100644 content/pages/docs-semantic-search.md create mode 100644 convex/cms.ts create mode 100644 convex/embeddings.ts create mode 100644 convex/embeddingsQueries.ts create mode 100644 convex/importAction.ts create mode 100644 convex/semanticSearch.ts create mode 100644 convex/semanticSearchQueries.ts create mode 100644 public/raw/docs-configuration.md create mode 100644 public/raw/docs-content.md create mode 100644 public/raw/docs-dashboard.md create mode 100644 public/raw/docs-deployment.md create mode 100644 public/raw/docs-frontmatter.md create mode 100644 public/raw/docs-search.md create mode 100644 public/raw/docs-semantic-search.md create mode 100644 scripts/export-db-posts.ts diff --git a/.claude/plans/librarian-integration.md b/.claude/plans/librarian-integration.md new file mode 100644 index 0000000..c000f8b --- /dev/null +++ b/.claude/plans/librarian-integration.md @@ -0,0 +1,156 @@ +# Librarian Integration Plan + +## Summary + +Integrate [Librarian](https://github.com/iannuttall/librarian) to provide hybrid search (word + vector) across external documentation sources, complementing the existing MCP server. + +## What is Librarian? + +- Local CLI tool that fetches and indexes developer documentation +- Hybrid search: word-based + vector/semantic +- Has its own MCP server (`librarian mcp`) +- Designed for AI coding agents + +## Integration Options + +### Option A: Side-by-Side MCP Servers (Recommended) + +No code changes needed. Document how users can configure both MCP servers: + +```json +// ~/.cursor/mcp.json or claude_desktop_config.json +{ + "mcpServers": { + "markdown-blog": { + "url": "https://yoursite.com/mcp" + }, + "librarian": { + "command": "librarian", + "args": ["mcp"] + } + } +} +``` + +**Pros:** Simple, no code changes, each tool does what it's best at +**Cons:** Two separate tools, no unified search + +### Option B: Librarian Indexes Your Blog + +Users add your site as a Librarian source: + +```bash +librarian add https://yoursite.com +librarian ingest +``` + +Librarian fetches your `/raw/*.md` files and indexes them with vector embeddings. + +**Pros:** Unified search across your content + external docs +**Cons:** Requires user setup, duplicate indexing + +### Option C: MCP Proxy (Advanced) + +Your `/mcp` endpoint proxies requests to a local Librarian instance for unified results. + +**Pros:** Single MCP endpoint, unified search +**Cons:** Complex, requires Librarian running locally on server + +## Recommended Approach: Option A + Documentation + +1. Create docs page explaining Librarian integration +2. Add Librarian config examples to MCP documentation +3. Optionally add a Claude skill for Librarian setup + +## Files to Create + +### 1. `content/blog/how-to-use-librarian.md` + +Blog post explaining: +- What Librarian is +- How to install it +- How to add documentation sources +- How to use with your MCP server side-by-side +- Example MCP configuration + +### 2. `.claude/skills/librarian.md` (optional) + +Claude skill for setting up Librarian: +- Installation instructions +- Common commands +- Integration with this blog's MCP + +## Files to Modify + +### 1. `content/pages/docs.md` + +Add section about Librarian integration under MCP documentation. + +### 2. `content/blog/how-to-use-mcp-server.md` + +Add example showing both MCP servers configured together. + +## Implementation Steps + +1. Write blog post explaining Librarian +2. Update MCP docs with side-by-side configuration +3. Optionally create Claude skill +4. Test configuration with Cursor/Claude Desktop + +## Example Documentation Content + +```markdown +## Using with Librarian + +For AI agents that need both your blog content AND external documentation +(React, Convex, etc.), configure both MCP servers: + +### Cursor Configuration + +Edit `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "your-blog": { + "url": "https://yoursite.com/mcp" + }, + "librarian": { + "command": "librarian", + "args": ["mcp"] + } + } +} +``` + +### Setup Librarian + +```bash +# Install +curl -fsSL https://raw.githubusercontent.com/iannuttall/librarian/main/install.sh | bash + +# Add documentation sources +librarian add convex https://github.com/get-convex/convex-backend +librarian add react https://github.com/facebook/react + +# Ingest and embed +librarian ingest +``` + +Now AI agents can search your blog via `your-blog` MCP and external docs via `librarian` MCP. +``` + +## Dependencies + +- Librarian installed locally (user responsibility) +- No changes to your codebase required for Option A + +## Future Enhancements + +- Option C proxy implementation if unified search is needed +- Librarian as a Netlify Edge Function (if Librarian supports it) +- Pre-configured Librarian sources for common dev docs + +## Status + +**Not started** - Documentation-only integration, no code changes required. diff --git a/.claude/skills/dev.md b/.claude/skills/dev.md index 4f8a6de..b25e3df 100644 --- a/.claude/skills/dev.md +++ b/.claude/skills/dev.md @@ -31,6 +31,7 @@ Expert full-stack and AI developer specializing in React, Vite, Bun, Clerk, Work - Auth functions: https://docs.convex.dev/auth/functions-auth - File storage: https://docs.convex.dev/file-storage/upload-files - Vector search: https://docs.convex.dev/search/vector-search +- frontmatter: https://frontmatter.codes/docs ## Authentication diff --git a/AGENTS.md b/AGENTS.md index 35665dc..eaba7ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar - **Total Posts**: 17 - **Total Pages**: 4 - **Latest Post**: 2025-12-29 -- **Last Updated**: 2026-01-04T17:25:36.680Z +- **Last Updated**: 2026-01-05T18:54:36.240Z ## Tech stack diff --git a/CLAUDE.md b/CLAUDE.md index fe71843..30302a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Project instructions for Claude Code. ## Project context - + Markdown sync framework. Write markdown in `content/`, run sync commands, content appears instantly via Convex real-time database. Built for developers and AI agents. diff --git a/TASK.md b/TASK.md index 01b3d5c..5398bbd 100644 --- a/TASK.md +++ b/TASK.md @@ -4,10 +4,50 @@ ## Current Status -v2.8.7 ready. Write page frontmatter sidebar toggle now works outside focus mode. +v2.10.0 ready. Semantic search with vector embeddings added to complement keyword search. ## Completed +- [x] Semantic search with vector embeddings + - [x] Dual search modes: Keyword (exact match) and Semantic (meaning-based) + - [x] Toggle between modes in search modal (Cmd+K) with TextAa and Brain icons + - [x] OpenAI text-embedding-ada-002 for generating 1536-dimension embeddings + - [x] Similarity scores displayed as percentages in search results + - [x] Graceful fallback when OPENAI_API_KEY not configured + - [x] Embeddings generated automatically during `npm run sync` + - [x] New `convex/embeddings.ts` with embedding generation actions + - [x] New `convex/embeddingsQueries.ts` with queries and mutations for embedding storage + - [x] New `convex/semanticSearch.ts` with vector search action + - [x] New `convex/semanticSearchQueries.ts` with internal queries + - [x] Added `embedding` field and `by_embedding` vector index to posts and pages + - [x] Updated SearchModal.tsx with mode toggle and semantic search integration + - [x] Documentation pages: `docs-search.md` and `docs-semantic-search.md` + +- [x] Dashboard Cloud CMS features + - [x] Dual source architecture: `source: "dashboard"` vs `source: "sync"` coexist independently + - [x] Direct database operations: "Save to DB" in Write sections, "Save Changes" in editor + - [x] Source badges in Posts and Pages list views (blue Dashboard, gray Synced) + - [x] Delete button for dashboard-created content only + - [x] Delete confirmation modal with warning icon and danger button styling + - [x] Server-side URL import via Firecrawl (direct to database) + - [x] Export to markdown for backup or file-based workflow conversion + - [x] Bulk export script: `npm run export:db` and `npm run export:db:prod` + - [x] New `convex/cms.ts` with CRUD mutations + - [x] New `convex/importAction.ts` with Firecrawl action + - [x] New `scripts/export-db-posts.ts` for bulk export + - [x] Updated sync mutations to preserve dashboard content + +- [x] Rich Text Editor (Quill) in Dashboard + - [x] Three editing modes: Markdown (default), Rich Text (Quill), Preview + - [x] Quill toolbar: headers, bold, italic, strikethrough, blockquote, code, lists, links + - [x] Automatic HTML-to-Markdown conversion on mode switch + - [x] Theme-aware styling using CSS variables + +- [x] Dashboard UI fixes + - [x] Fixed source badge overlap with edit pencil in list rows + - [x] Adjusted grid column widths for proper badge display + - [x] Added source-badge CSS styles with proper spacing + - [x] Write page frontmatter sidebar toggle fix - [x] Added CSS rules for `.write-layout.frontmatter-collapsed` to adjust grid when sidebar collapsed - [x] Added CSS rules for `.write-layout.sidebar-collapsed.frontmatter-collapsed` for both sidebars collapsed diff --git a/changelog.md b/changelog.md index 83cd555..5f997d6 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,81 @@ 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/). +## [2.10.0] - 2026-01-05 + +### Added + +- Semantic search using vector embeddings to complement existing keyword search + - Toggle between "Keyword" and "Semantic" modes in search modal (Cmd+K) + - Keyword search: exact word matching via Convex full-text search indexes (instant, free) + - Semantic search: finds content by meaning using OpenAI text-embedding-ada-002 embeddings (~300ms, ~$0.0001/query) + - Similarity scores displayed as percentages (90%+ = very similar, 70-90% = related) + - Graceful fallback: semantic search returns empty results if OPENAI_API_KEY not configured +- Embedding generation during content sync + - Embeddings generated automatically for posts and pages during `npm run sync` + - Title and content combined for embedding generation + - Content truncated to 8000 characters to stay within token limits +- New documentation pages + - `docs-search.md`: Keyword search implementation with ASCII flowchart + - `docs-semantic-search.md`: Semantic search guide with comparison table + +### Technical + +- New file: `convex/embeddings.ts` - Actions for embedding generation (Node.js runtime) +- New file: `convex/embeddingsQueries.ts` - Queries and mutations for embedding storage +- New file: `convex/semanticSearch.ts` - Vector search action with similarity scoring +- New file: `convex/semanticSearchQueries.ts` - Internal queries for hydrating search results +- Added `embedding` field (optional float64 array) to posts and pages tables in schema +- Added `by_embedding` vector index (1536 dimensions, filterFields: ["published"]) to posts and pages +- Updated `src/components/SearchModal.tsx` with mode toggle (TextAa/Brain icons) and semantic search integration +- Updated `scripts/sync-posts.ts` to call `generateMissingEmbeddings` after content sync +- Added search mode toggle CSS styles (.search-mode-toggle, .search-mode-btn) + +### Environment Variables + +- `OPENAI_API_KEY`: Required for semantic search (set via `npx convex env set OPENAI_API_KEY sk-xxx`) + +## [2.9.0] - 2026-01-04 + +### Added + +- Dashboard Cloud CMS features for WordPress-style content management + - Dual source architecture: dashboard-created content (`source: "dashboard"`) and synced content (`source: "sync"`) coexist independently + - Source badges in Posts and Pages list views (blue "Dashboard", gray "Synced") + - Direct database operations: "Save to DB" button in Write sections, "Save Changes" in editor + - Delete button for dashboard-created content with confirmation modal + - Server-side URL import via Firecrawl (direct to database, no file sync needed) + - Export to markdown functionality for backup or converting to file-based workflow + - Bulk export script: `npm run export:db` and `npm run export:db:prod` +- Rich Text Editor in Write Post/Page sections + - Three editing modes: Markdown (default), Rich Text (Quill WYSIWYG), Preview + - Quill toolbar: headers, bold, italic, strikethrough, blockquote, code, lists, links + - Automatic HTML-to-Markdown conversion when switching modes + - Theme-aware styling +- Delete confirmation modal for posts and pages + - Warning icon and danger-styled delete button + - Shows item name and type being deleted + - Backdrop click and Escape key to cancel + +### Changed + +- Posts and Pages list view grid layout adjusted for source badges + - Column widths: title (1fr), date (110px), status (170px), actions (110px) + - Added flex-wrap and gap for status column content +- Sync mutations now preserve dashboard-created content + - Only affects content with `source: "sync"` or no source field + +### Technical + +- New file: `convex/cms.ts` with CRUD mutations for dashboard content +- New file: `convex/importAction.ts` with Firecrawl server-side action +- New file: `scripts/export-db-posts.ts` for bulk markdown export +- Added `source` field (optional union: "dashboard" | "sync") to posts and pages tables +- Added `by_source` index to posts and pages tables in schema +- Added ConfirmDeleteModal component with Warning icon from Phosphor +- Added source-badge CSS styles (.source-badge, .source-badge.dashboard, .source-badge.sync) +- Added delete modal styles (.dashboard-modal-delete, .dashboard-modal-icon-warning, .dashboard-modal-btn.danger) + ## [2.8.7] - 2026-01-04 ### Fixed diff --git a/content/blog/netlify-edge-excludedpath-ai-crawlers.md b/content/blog/netlify-edge-excludedpath-ai-crawlers.md index c697150..2698415 100644 --- a/content/blog/netlify-edge-excludedpath-ai-crawlers.md +++ b/content/blog/netlify-edge-excludedpath-ai-crawlers.md @@ -1,17 +1,32 @@ --- -title: "Netlify edge functions blocking AI crawlers from static files" -description: "Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works." +title: "How we fixed AI crawlers blocked by Netlify edge functions" +description: "ChatGPT and Perplexity couldn't fetch /raw/*.md files on Netlify. The fix: Content-Type headers. Here's what we tried and what actually worked." date: "2025-12-14" slug: "netlify-edge-excludedpath-ai-crawlers" published: true -tags: ["netlify", "edge-functions", "ai", "troubleshooting", "help"] +tags: ["netlify", "edge-functions", "ai", "troubleshooting"] readTime: "5 min read" featured: false --- +## The fix + +Add explicit `Content-Type` headers for your raw markdown files in `netlify.toml`: + +```toml +[[headers]] + for = "/raw/*" + [headers.values] + Content-Type = "text/plain; charset=utf-8" + Access-Control-Allow-Origin = "*" + Cache-Control = "public, max-age=3600" +``` + +Thanks to [KP](https://x.com/thisiskp_) for pointing us in the right direction. + ## The problem -AI crawlers cannot access static markdown files at `/raw/*.md` on Netlify, even with `excludedPath` configured. ChatGPT and Perplexity return errors. Claude works. +AI crawlers could not access static markdown files at `/raw/*.md` on Netlify, even with `excludedPath` configured. ChatGPT and Perplexity returned errors. Claude worked. ## What we're building @@ -164,15 +179,19 @@ The core issue appears to be how ChatGPT and Perplexity fetch URLs. Their tools 2. The edge function exclusions work for browsers but not for AI fetch tools 3. There may be rate limiting or bot protection enabled by default -## Current workaround +## Why Content-Type matters -Users can still share content with AI tools by: +Without an explicit `Content-Type` header, Netlify serves files based on extension. The `.md` extension gets served as `text/markdown` or similar, which AI fetch tools may reject or misinterpret. -1. **Copy page** copies markdown to clipboard, then paste into any AI -2. **View as Markdown** opens the raw `.md` file in a browser tab for manual copying -3. **Download as SKILL.md** downloads in Anthropic Agent Skills format +Setting `Content-Type = "text/plain; charset=utf-8"` tells the CDN and AI crawlers exactly what to expect. The `Access-Control-Allow-Origin = "*"` header ensures cross-origin requests work. -The direct "Open in ChatGPT/Claude/Perplexity" buttons have been disabled since the URLs don't work reliably. +## What works now + +Users can share content with AI tools via: + +1. **Copy page** copies markdown to clipboard +2. **View as Markdown** opens the raw `.md` file in browser +3. **Open in ChatGPT/Claude/Perplexity** sends the URL directly (now working) ## Working features diff --git a/content/blog/setup-guide.md b/content/blog/setup-guide.md index 6934470..ff491b8 100644 --- a/content/blog/setup-guide.md +++ b/content/blog/setup-guide.md @@ -1,5 +1,5 @@ --- -title: "Setup Guide - Fork and Deploy Your Own Markdown Framework" +title: "Setup Guide" description: "Step-by-step guide to fork this markdown sync framework, set up Convex backend, and deploy to Netlify in under 10 minutes." date: "2025-12-14" slug: "setup-guide" @@ -348,29 +348,29 @@ 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) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | -| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | -| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | -| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | -| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | -| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | -| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | -| `docsLanding` | No | Set `true` to use as the docs landing page (shown when navigating to `/docs`). | +| 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) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | +| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | +| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | +| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | +| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | +| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | +| `docsLanding` | No | Set `true` to use as the docs landing page (shown when navigating to `/docs`). | ### How Frontmatter Works diff --git a/content/blog/team-workflows-git-version-control.md b/content/blog/team-workflows-git-version-control.md index ab82f3a..7062955 100644 --- a/content/blog/team-workflows-git-version-control.md +++ b/content/blog/team-workflows-git-version-control.md @@ -1,5 +1,5 @@ --- -title: "Team Workflows with Git Version Control" +title: "Team Workflows" description: "How teams collaborate on markdown content using git, sync to shared Convex deployments, and automate production syncs with CI/CD." date: "2025-12-29" slug: "team-workflows-git-version-control" diff --git a/content/pages/about.md b/content/pages/about.md index a9aa2f4..f9a9866 100644 --- a/content/pages/about.md +++ b/content/pages/about.md @@ -83,7 +83,9 @@ It's a hybrid: developer workflow for publishing + real-time delivery like a dyn **Search and discovery:** -- Full text search with Command+K shortcut +- Dual search modes: Keyword (exact match) and Semantic (meaning-based) with Cmd+K toggle +- Semantic search uses OpenAI embeddings for finding conceptually similar content +- Full text search with Command+K shortcut and result highlighting - Static raw markdown files at `/raw/{slug}.md` - RSS feeds (`/rss.xml` and `/rss-full.xml`) and sitemap for SEO - API endpoints for AI/LLM access (`/api/posts`, `/api/export`) diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md index 57540e2..8df03d8 100644 --- a/content/pages/changelog-page.md +++ b/content/pages/changelog-page.md @@ -11,6 +11,117 @@ docsSectionOrder: 4 All notable changes to this project. +## v2.10.0 + +Released January 5, 2026 + +**Semantic search with vector embeddings** + +Search now supports two modes accessible via Cmd+K: + +- **Keyword search** (existing) - Matches exact words using Convex full-text search. Instant, free, supports highlighting. +- **Semantic search** (new) - Finds content by meaning using OpenAI embeddings. Toggle to "Semantic" mode in search modal. + +**How semantic search works:** + +1. Your query is converted to a 1536-dimension vector using OpenAI text-embedding-ada-002 +2. Convex compares this vector to stored embeddings for all posts and pages +3. Results ranked by similarity score (displayed as percentage) +4. Top 15 results returned + +**When to use each mode:** + +| Use Case | Mode | +|----------|------| +| Specific code, commands, exact phrases | Keyword | +| Conceptual questions ("how do I deploy?") | Semantic | +| Need to highlight matches on page | Keyword | +| Not sure of exact terminology | Semantic | + +**Configuration:** + +Semantic search requires an OpenAI API key: + +```bash +npx convex env set OPENAI_API_KEY sk-your-key-here +npm run sync # Generates embeddings for all content +``` + +If OPENAI_API_KEY is not configured, semantic search returns empty results and keyword search continues to work normally. + +**Technical details:** + +- New files: `convex/embeddings.ts`, `convex/embeddingsQueries.ts`, `convex/semanticSearch.ts`, `convex/semanticSearchQueries.ts` +- Added `embedding` field and `by_embedding` vector index to posts and pages tables +- SearchModal.tsx updated with Keyword/Semantic toggle (TextAa and Brain icons) +- Embeddings generated automatically during `npm run sync` +- Cost: ~$0.0001 per search query (embedding generation) + +Updated files: `convex/schema.ts`, `convex/embeddings.ts`, `convex/embeddingsQueries.ts`, `convex/semanticSearch.ts`, `convex/semanticSearchQueries.ts`, `src/components/SearchModal.tsx`, `scripts/sync-posts.ts`, `src/styles/global.css`, `content/pages/docs-search.md`, `content/pages/docs-semantic-search.md` + +## v2.9.0 + +Released January 4, 2026 + +**Dashboard Cloud CMS Features** + +The Dashboard now functions as a WordPress-style cloud CMS, allowing content creation and editing directly in the database without requiring the markdown file sync workflow. + +**Dual Source Architecture:** + +- Dashboard-created content marked with `source: "dashboard"` +- Markdown-synced content marked with `source: "sync"` +- Both coexist independently in the database +- Sync operations only affect synced content (dashboard content protected) +- Source badges in Posts and Pages list views (blue "Dashboard", gray "Synced") + +**Direct Database Operations:** + +- "Save to DB" button in Write Post/Page sections saves directly to database +- "Save Changes" button in Post/Page editor updates content immediately +- Delete button for dashboard-created content (synced content protected) +- Changes appear instantly without requiring sync + +**Delete Confirmation Modal:** + +- Warning modal displayed before deleting posts or pages +- Shows item name and type being deleted +- Themed to match dashboard UI with danger button styling +- Backdrop click and Escape key to cancel + +**Rich Text Editor:** + +- Three editing modes: Markdown (default), Rich Text (Quill WYSIWYG), Preview +- Quill-based editor with formatting toolbar +- Toolbar: headers (H1-H3), bold, italic, strikethrough, blockquote, code, lists, links +- Automatic HTML-to-Markdown conversion when switching modes +- Theme-aware styling using CSS variables + +**Server-Side URL Import:** + +- Direct database import via Firecrawl (no file sync needed) +- Enter URL in Import section, content is scraped and saved to database +- Optional "Publish immediately" checkbox +- Imported posts tagged with `imported` by default +- Requires `FIRECRAWL_API_KEY` in Convex environment variables + +**Export to Markdown:** + +- Export any post/page to `.md` file with complete frontmatter +- Bulk export script: `npm run export:db` (dev) or `npm run export:db:prod` (prod) +- Use for backup or converting dashboard content to file-based workflow + +**Technical details:** + +- New file: `convex/cms.ts` with CRUD mutations +- New file: `convex/importAction.ts` with Firecrawl server-side action +- New file: `scripts/export-db-posts.ts` for bulk markdown export +- Added `source` field and `by_source` index to posts and pages tables +- Added ConfirmDeleteModal component to Dashboard.tsx +- Fixed list row grid layout for proper source badge display + +Updated files: `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `convex/cms.ts`, `convex/importAction.ts`, `scripts/export-db-posts.ts`, `src/pages/Dashboard.tsx`, `src/styles/global.css`, `package.json`, `content/pages/docs-dashboard.md` + ## v2.8.7 Released January 4, 2026 diff --git a/content/pages/docs-configuration.md b/content/pages/docs-configuration.md new file mode 100644 index 0000000..1eb5102 --- /dev/null +++ b/content/pages/docs-configuration.md @@ -0,0 +1,444 @@ +--- +title: "Configuration" +slug: "docs-configuration" +published: true +order: 4 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 4 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Configuration + +### Fork configuration + +After forking, you have two options to configure your site: + +**Option 1: Automated (Recommended)** + +```bash +cp fork-config.json.example fork-config.json +# Edit fork-config.json with your site information +npm run configure +``` + +This updates all 11 configuration files in one command. See `FORK_CONFIG.md` for the full JSON schema and options. + +**Option 2: Manual** + +Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually. + +### Files updated by configuration + +| File | What to update | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `src/config/siteConfig.ts` | Site name, title, intro, bio, blog page, logo gallery, GitHub contributions, right sidebar configuration | +| `src/pages/Home.tsx` | Intro paragraph text, footer links | +| `convex/http.ts` | `SITE_URL`, `SITE_NAME`, description strings (3 locations) | +| `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, topics | +| `public/robots.txt` | Sitemap URL and header comment | +| `public/openapi.yaml` | API title, server URL, site name in examples | +| `public/.well-known/ai-plugin.json` | Site name, descriptions | +| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) | + +### Site title and description metadata + +These files contain the main site description text. Update them with your own tagline: + +| File | What to change | +| --------------------------------- | -------------------------------------------------------------- | +| `index.html` | meta description, og:description, twitter:description, JSON-LD | +| `README.md` | Main description at top of file | +| `src/config/siteConfig.ts` | name, title, and bio fields | +| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) | +| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) | +| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants | +| `public/llms.txt` | Header quote, Name, and Description fields | +| `public/openapi.yaml` | API title and example site name | +| `AGENTS.md` | Project overview section | +| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph | +| `content/pages/about.md` | excerpt field and opening paragraph | +| `content/pages/docs.md` | Opening description paragraph | + +**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. + +### Site settings + +Edit `src/config/siteConfig.ts`: + +```typescript +export default { + name: "Site Name", + title: "Tagline", + logo: "/images/logo.svg", // null to hide homepage logo + intro: "Introduction text...", + bio: "Bio text...", + + // Blog page configuration + blogPage: { + enabled: true, // Enable /blog route + showInNav: true, // Show in navigation + title: "Blog", // Nav link and page title + order: 0, // Nav order (lower = first) + }, + + // Hardcoded navigation items for React routes + hardcodedNavItems: [ + { + slug: "stats", + title: "Stats", + order: 10, + showInNav: true, // Set to false to hide from nav + }, + { + slug: "write", + title: "Write", + order: 20, + showInNav: true, + }, + ], + + // Inner page logo configuration + innerPageLogo: { + enabled: true, // Set to false to hide logo on inner pages + size: 28, // Logo height in pixels (keeps aspect ratio) + }, + + // Featured section + featuredViewMode: "list", // 'list' or 'cards' + showViewToggle: true, + + // Logo gallery (static grid or scrolling marquee) + logoGallery: { + enabled: true, // false to hide + images: [{ src: "/images/logos/logo.svg", href: "https://example.com" }], + position: "above-footer", + speed: 30, + title: "Built with", + scrolling: false, // false = static grid, true = scrolling marquee + maxItems: 4, // Number of logos when scrolling is false + }, + + links: { + docs: "/docs", + convex: "https://convex.dev", + }, +}; +``` + +**Logo configuration:** + +- `logo`: Homepage logo path (set to `null` to hide). Uses `public/images/logo.svg` by default. +- `innerPageLogo`: Logo shown on blog page, posts, and static pages. Desktop: top left. Mobile: top right. Set `enabled: false` to hide on inner pages while keeping homepage logo. + +**Navigation structure:** + +Navigation combines three sources sorted by `order`: + +1. Blog link (if `blogPage.enabled` and `blogPage.showInNav` are true) +2. Hardcoded nav items (React routes from `hardcodedNavItems`) +3. Markdown pages (from `content/pages/` with `showInNav: true`) + +All items sort by `order` (lower first), then alphabetically by title. + +### 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` or `npm run sync:all`. 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 +}; +``` + +### GitHub contributions graph + +Display your GitHub contribution activity on the homepage. Configure in `siteConfig`: + +```typescript +gitHubContributions: { + enabled: true, // Set to false to hide + username: "yourusername", // Your GitHub username + showYearNavigation: true, // Show arrows to navigate between years + linkToProfile: true, // Click graph to open GitHub profile + title: "GitHub Activity", // Optional title above the graph +}, +``` + +| Option | Description | +| -------------------- | -------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `username` | Your GitHub username | +| `showYearNavigation` | Show prev/next year navigation | +| `linkToProfile` | Click graph to visit GitHub profile | +| `title` | Text above graph (`undefined` to hide) | + +Theme-aware colors match each site theme. Uses public API (no GitHub token required). + +### Visitor map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +```typescript +visitorMap: { + enabled: true, // Set to false to hide + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------ | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (`undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + +### Logo gallery + +The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. + +```typescript +// In src/config/siteConfig.ts +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: "Built with", // undefined to hide + scrolling: false, // false = static grid, true = scrolling marquee + maxItems: 4, // Number of logos when scrolling is false +}, +``` + +| 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) | +| `scrolling` | `true` for infinite scroll, `false` for static grid | +| `maxItems` | Max logos to show when `scrolling` is `false` (default: 4) | + +**Display modes:** + +- `scrolling: true`: Infinite horizontal scroll with all logos +- `scrolling: false`: Static centered grid showing first `maxItems` logos + +**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. + +### Blog page + +The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`: + +```typescript +blogPage: { + enabled: true, // Enable /blog route + showInNav: true, // Show in navigation + title: "Blog", // Nav link and page title + order: 0, // Nav order (lower = first) + viewMode: "list", // Default view: "list" or "cards" + showViewToggle: true, // Show toggle button to switch views +}, +displayOnHomepage: true, // Show posts on homepage +``` + +| Option | Description | +| ------------------- | -------------------------------------- | +| `enabled` | Enable the `/blog` route | +| `showInNav` | Show Blog link in navigation | +| `title` | Text for nav link and page heading | +| `order` | Position in navigation (lower = first) | +| `viewMode` | Default view: `"list"` or `"cards"` | +| `showViewToggle` | Show toggle button to switch views | +| `displayOnHomepage` | Show post list on homepage | + +**View modes:** + +- **List view:** Year-grouped posts with titles, read time, and dates +- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata + +**Card view details:** + +Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile. + +**Display options:** + +- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false` +- Blog page only: `displayOnHomepage: false`, `blogPage.enabled: true` +- Both: `displayOnHomepage: true`, `blogPage.enabled: true` + +**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4. + +**View preference:** User's view mode choice is saved to localStorage and persists across page visits. + +### Scroll-to-top button + +A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`: + +```typescript +const scrollToTopConfig: Partial = { + 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`. + +Configure in `src/config/siteConfig.ts`: + +```typescript +export const siteConfig: SiteConfig = { + // ... other config + defaultTheme: "tan", +}; +``` + +### Font + +Configure the font in `src/config/siteConfig.ts`: + +```typescript +export const siteConfig: SiteConfig = { + // ... other config + fontFamily: "serif", // Options: "serif", "sans", or "monospace" +}; +``` + +Or edit `src/styles/global.css` directly: + +```css +body { + /* Sans-serif */ + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Serif (default) */ + font-family: "New York", ui-serif, Georgia, serif; + + /* Monospace */ + font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace; +} +``` + +Available options: `serif` (default), `sans`, or `monospace`. + +### Font sizes + +All font sizes use CSS variables in `:root`. Customize by editing: + +```css +:root { + --font-size-base: 16px; + --font-size-sm: 13px; + --font-size-lg: 17px; + --font-size-blog-content: 17px; + --font-size-post-title: 32px; +} +``` + +Mobile sizes defined in `@media (max-width: 768px)` block. + +### 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 | + +**Images require git deploy.** Images are served as static files from your repository, not synced to Convex. After adding images to `public/images/`: + +1. Commit the image files to git +2. Push to GitHub +3. Wait for Netlify to rebuild + +The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes. + +**Adding images to posts:** You can add images using markdown syntax `![alt](src)` or HTML `` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices. + +**Logo options:** + +- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide. +- **Inner page logo:** Configured via `innerPageLogo` in `siteConfig.ts`. Shows on blog page, posts, and static pages. Desktop: top left corner. Mobile: top right corner (smaller). Set `enabled: false` to hide on inner pages while keeping homepage logo. diff --git a/content/pages/docs-content.md b/content/pages/docs-content.md new file mode 100644 index 0000000..87ce6c4 --- /dev/null +++ b/content/pages/docs-content.md @@ -0,0 +1,307 @@ +--- +title: "Content" +slug: "docs-content" +published: true +order: 2 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 2 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Content + +**Markdown examples:** For complete markdown syntax examples including code blocks, tables, lists, links, images, collapsible sections, and all formatting options, see [Writing Markdown with Code Examples](/markdown-with-code-examples). That post includes copy-paste examples for every markdown feature. + +### 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... +``` + +See the [Frontmatter](/docs-frontmatter) page for all available fields. + +### Static pages + +Create files in `content/pages/` with frontmatter: + +```markdown +--- +title: "Page Title" +slug: "url-path" +published: true +order: 1 +--- + +Content here... +``` + +See the [Frontmatter](/docs-frontmatter) page for all available fields. + +### Home intro content + +The homepage intro text can be synced from markdown via `content/pages/home.md` (slug: `home-intro`). This allows you to update homepage text without redeploying. + +**Create home intro:** + +1. Create `content/pages/home.md`: + +```markdown +--- +title: "Home Intro" +slug: "home-intro" +published: true +showInNav: false +order: -1 +textAlign: "left" +--- + +Your homepage intro text here. + +## Features + +**Feature one** - Description here. + +**Feature two** - Description here. +``` + +2. Run `npm run sync` to sync to Convex + +3. Content appears on homepage instantly (no rebuild needed) + +**Blog heading styles:** Headings (h1-h6) in home intro content use the same styling as blog posts (`blog-h1` through `blog-h6` classes). Each heading gets an automatic ID and a clickable anchor link (#) that appears on hover. Lists, blockquotes, horizontal rules, and links also use blog styling classes for consistent typography. + +**Fallback:** If `home-intro` page is not found, the homepage falls back to `siteConfig.bio` text. + +### Footer content + +The footer content can be synced from markdown via `content/pages/footer.md` (slug: `footer`). This allows you to update footer text without touching code. + +**Create footer content:** + +1. Create `content/pages/footer.md`: + +```markdown +--- +title: "Footer" +slug: "footer" +published: true +showInNav: false +order: -1 +--- + +Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). + +Created by [Your Name](https://x.com/yourhandle). Follow on [Twitter/X](https://x.com/yourhandle) and [GitHub](https://github.com/yourusername). +``` + +2. Run `npm run sync` to sync to Convex + +3. Footer content appears on homepage, blog page, and all posts/pages instantly (no rebuild needed) + +**Markdown support:** Footer content supports full markdown including links, paragraphs, line breaks, and images. External links automatically open in new tabs. + +**Fallback:** If `footer` page is not found, the footer falls back to `siteConfig.footer.defaultContent`. + +**Priority order:** Per-post/page frontmatter `footer:` field (custom override) > synced footer.md content > siteConfig.footer.defaultContent. + +**Relationship with siteConfig:** The `content/pages/footer.md` page takes priority over `siteConfig.footer.defaultContent` when present. Use the markdown page for dynamic content that changes frequently, or keep using siteConfig for static footer content. + +### Sidebar layout + +Posts and pages can use a docs-style layout with a table of contents sidebar. Add `layout: "sidebar"` to the frontmatter: + +```markdown +--- +title: "Documentation" +slug: "docs" +published: true +layout: "sidebar" +--- + +# Introduction + +## Section One + +### Subsection + +## Section Two +``` + +**Features:** + +- Left sidebar displays table of contents extracted from H1, H2, H3 headings +- Two-column layout: 220px sidebar + flexible content area +- Sidebar only appears if headings exist in the content +- Active heading highlighting as you scroll +- Smooth scroll navigation when clicking TOC links +- Mobile responsive: stacks to single column below 1024px +- Works for both blog posts and static pages + +The sidebar extracts headings automatically from your markdown content. No manual TOC needed. + +### Right sidebar + +When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. + +**Configuration:** + +Enable globally in `src/config/siteConfig.ts`: + +```typescript +rightSidebar: { + enabled: true, // Set to false to disable right sidebar globally + minWidth: 1135, // Minimum viewport width to show sidebar +}, +``` + +Control per post/page with frontmatter: + +```markdown +--- +title: "My Post" +rightSidebar: true # Enable right sidebar for this post +--- +``` + +**Features:** + +- Right sidebar appears at 1135px+ viewport width +- Contains CopyPageDropdown with all sharing options +- Three-column layout: left sidebar (TOC), main content, right sidebar +- CopyPageDropdown automatically moves from nav to right sidebar when enabled +- Hidden below 1135px breakpoint, CopyPageDropdown returns to nav +- Per-post/page control via `rightSidebar: true` frontmatter field +- Opt-in only: right sidebar only appears when explicitly enabled in frontmatter + +**Use cases:** + +- Keep CopyPageDropdown accessible on wide screens without cluttering the nav +- Provide quick access to sharing options while reading long content +- Works alongside left sidebar TOC for comprehensive navigation + +**Example for blog post:** + +```markdown +--- +title: "My Tutorial" +description: "A detailed guide" +date: "2025-01-20" +slug: "my-tutorial" +published: true +tags: ["tutorial"] +layout: "sidebar" +--- + +# Introduction + +## Getting Started + +### Prerequisites + +## Advanced Topics +``` + +### How frontmatter works + +Frontmatter is the YAML metadata at the top of each markdown file between `---` markers. Here is how it flows through the system: + +**Content directories:** + +- `content/blog/*.md` contains blog posts with frontmatter +- `content/pages/*.md` contains static pages with frontmatter + +**Processing flow:** + +1. Markdown files in `content/blog/` and `content/pages/` contain YAML frontmatter +2. `scripts/sync-posts.ts` uses `gray-matter` to parse frontmatter and validate required fields +3. Parsed data is sent to Convex mutations (`api.posts.syncPostsPublic`, `api.pages.syncPagesPublic`) +4. `convex/schema.ts` defines the database structure for storing the data + +**Adding a new frontmatter field:** + +To add a custom frontmatter field, update these files: + +1. The interface in `scripts/sync-posts.ts` (`PostFrontmatter` or `PageFrontmatter`) +2. The parsing logic in `parseMarkdownFile()` or `parsePageFile()` functions +3. The schema in `convex/schema.ts` +4. The sync mutation in `convex/posts.ts` or `convex/pages.ts` + +### Syncing content + +**Development:** + +```bash +npm run sync # Sync markdown content +npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt) +npm run sync:all # Sync content + discovery files together +``` + +**Production:** + +```bash +npm run sync:prod # Sync markdown content +npm run sync:discovery:prod # Update discovery files +npm run sync:all:prod # Sync content + discovery files together +``` + +**Sync everything together:** + +```bash +npm run sync:all # Development: content + discovery +npm run sync:all:prod # Production: content + discovery +``` + +### 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) | +| Site config changes | `npm run sync:discovery` | Updates discovery files | +| Import external URL | `npm run import` then sync | Instant (no rebuild) | +| Images in `public/images/` | Git commit + push | Requires rebuild | +| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | +| Logo gallery config | Redeploy | Requires rebuild | +| React components/styles | Redeploy | Requires rebuild | + +**Markdown content** syncs instantly to Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild. + +## Tag pages and related posts + +Tag pages are available at `/tags/[tag]` for each tag used in your posts. They display all posts with that tag in a list or card view with localStorage persistence for view mode preference. + +**Related posts:** Individual blog posts show up to 3 related posts in the footer based on shared tags. Posts are sorted by relevance (number of shared tags) then by date. Only appears on blog posts (not static pages). + +**Tag links:** Tags in post footers link to their respective tag archive pages. + +## Blog page featured layout + +Posts can be marked as featured on the blog page using the `blogFeatured` frontmatter field: + +```yaml +--- +title: "My Featured Post" +blogFeatured: true +--- +``` + +The first `blogFeatured` post displays as a hero card with landscape image, tags, date, title, excerpt, author info, and read more link. Remaining `blogFeatured` posts display in a 2-column featured row with excerpts. Regular (non-featured) posts display in a 3-column grid without excerpts. diff --git a/content/pages/docs-dashboard.md b/content/pages/docs-dashboard.md new file mode 100644 index 0000000..d258d47 --- /dev/null +++ b/content/pages/docs-dashboard.md @@ -0,0 +1,400 @@ +--- +title: "Dashboard" +slug: "docs-dashboard" +published: true +order: 5 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 5 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Dashboard + +The Dashboard at `/dashboard` provides a centralized UI for managing content, configuring the site, and performing sync operations. It's designed for developers who fork the repository to set up and manage their markdown blog. + +**Access:** Navigate to `/dashboard` in your browser. The dashboard is not linked in the navigation by default (similar to Newsletter Admin pattern). + +**Authentication:** WorkOS authentication is optional. Configure it in `siteConfig.ts`: + +```typescript +dashboard: { + enabled: true, + requireAuth: false, // Set to true to require WorkOS authentication +}, +``` + +When `requireAuth` is `false`, the dashboard is open access. When `requireAuth` is `true` and WorkOS is configured, users must log in to access the dashboard. See [How to setup WorkOS](https://www.markdown.fast/how-to-setup-workos) for authentication setup. + +### Content management + +**Posts and Pages List Views:** + +- View all posts and pages (published and unpublished) +- Filter by status: All, Published, Drafts +- Search by title or content +- Pagination with "First" and "Next" buttons +- Items per page selector (15, 25, 50, 100) - default: 15 +- Edit, view, and publish/unpublish options +- WordPress-style UI with date, edit, view, and publish controls +- **Source Badge:** Shows "Dashboard" or "Synced" to indicate content origin +- **Delete Button:** Delete dashboard-created posts/pages directly (synced content protected) + +**Post and Page Editor:** + +- Markdown editor with live preview +- Frontmatter sidebar on the right with all available fields +- Draggable/resizable frontmatter sidebar (200px-600px width) +- Independent scrolling for frontmatter sidebar +- Preview mode shows content as it appears on the live site +- Download markdown button to generate `.md` files +- Copy markdown to clipboard +- All frontmatter fields editable in sidebar +- Preview uses ReactMarkdown with proper styling +- **Save to Database:** Green "Save" button saves changes directly to the database + +**Write Post and Write Page:** + +- Full-screen writing interface +- **Three Editor Modes:** + - **Markdown:** Raw markdown editing (default) + - **Rich Text:** WYSIWYG Quill editor with toolbar + - **Preview:** Rendered markdown preview +- Word/line/character counts +- Frontmatter reference panel +- Download markdown button for new content +- **Save to DB:** Save directly to database without file sync +- Content persists in localStorage +- Separate storage for post and page content + +### Cloud CMS features + +The dashboard functions as a cloud-based CMS similar to WordPress, allowing you to create and edit content directly in the database without requiring the markdown file sync workflow. + +**Dual Source Architecture:** + +- **Dashboard Content:** Posts/pages created via "Save to DB" are marked with `source: "dashboard"` +- **Synced Content:** Posts/pages from markdown files are marked with `source: "sync"` +- Both coexist independently in the database +- Sync operations only affect synced content (dashboard content is protected) + +**Direct Database Operations:** + +- Create new posts/pages directly in the database +- Edit any post/page and save changes immediately +- Delete dashboard-created content +- Changes appear instantly (no sync required) + +**Export to Markdown:** + +- Any post/page can be exported as a `.md` file +- Includes all frontmatter fields +- Use for backup or converting to file-based workflow + +**Bulk Export Script:** + +```bash +npm run export:db # Export dashboard posts to content/blog/ +npm run export:db:prod # Export from production database +``` + +Exports all dashboard-created posts and pages to markdown files in the content folders. + +### Rich Text Editor + +The Write Post and Write Page sections include a Quill-based rich text editor with three editing modes. + +**Editing Modes:** + +- **Markdown:** Raw markdown text editing (default mode) +- **Rich Text:** WYSIWYG editor with formatting toolbar +- **Preview:** Rendered preview of the content + +**Rich Text Toolbar:** + +- Headers (H1, H2, H3) +- Bold, italic, strikethrough +- Blockquote, code block +- Ordered and bullet lists +- Links +- Clear formatting + +**Mode Switching:** + +- Content automatically converts between HTML and Markdown when switching modes +- Frontmatter is preserved when editing in Rich Text mode +- Preview mode shows how content will appear on the live site + +**Theme Integration:** + +- Editor styling matches the current theme (dark, light, tan, cloud) +- Toolbar uses CSS variables for consistent appearance + +### AI Agent + +The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat and Image Generation. + +**Chat Tab:** + +- Multi-model selector: Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash +- Per-session chat history stored in Convex +- Markdown rendering for AI responses +- Copy functionality for AI responses +- Lazy API key validation (errors only shown when user tries to use a specific model) + +**Image Tab:** + +- AI image generation with two models: + - Nano Banana (gemini-2.0-flash-exp-image-generation) - Experimental model + - Nano Banana Pro (imagen-3.0-generate-002) - Production model +- Aspect ratio selection: 1:1, 16:9, 9:16, 4:3, 3:4 +- Images stored in Convex storage with session tracking +- Gallery view of recent generated images + +**Environment Variables (Convex):** + +| Variable | Description | +| ------------------- | -------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 | +| `OPENAI_API_KEY` | Required for GPT-4o | +| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | + +**Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model. + +### Newsletter management + +All Newsletter Admin features integrated into the Dashboard: + +- **Subscribers:** View, search, filter, and delete subscribers +- **Send Newsletter:** Select a blog post to send as newsletter +- **Write Email:** Compose custom emails with markdown support +- **Recent Sends:** View last 10 newsletter sends (posts and custom emails) +- **Email Stats:** Dashboard with total emails, newsletters sent, active subscribers, retention rate + +All newsletter sections are full-width in the dashboard content area. + +### Content import + +**Direct Database Import:** + +The Import URL section uses server-side Firecrawl to import articles directly to the database. + +- Enter any article URL to import +- Firecrawl scrapes and converts content to markdown +- Post is saved directly to the database (no file sync needed) +- Optional "Publish immediately" checkbox +- Imported posts tagged with `imported` by default +- Source attribution added automatically +- Success message with link to view the imported post + +**Setup:** + +Add `FIRECRAWL_API_KEY` to your Convex environment variables: + +```bash +npx convex env set FIRECRAWL_API_KEY your-api-key-here +``` + +Get your API key from [firecrawl.dev](https://firecrawl.dev). + +**CLI Import (Alternative):** + +You can also import via command line: + +```bash +npm run import # Import URL as local markdown file +``` + +This creates a file in `content/blog/` that requires syncing. + +### Site configuration + +**Config Generator:** + +- UI to configure all settings in `src/config/siteConfig.ts` +- Generates downloadable `siteConfig.ts` file +- Hybrid approach: dashboard generates config, file-based config continues to work +- Includes all site configuration options: + - Site name, title, logo, bio, intro + - Blog page settings + - Featured section configuration + - Logo gallery settings + - GitHub contributions + - Footer and social footer + - Newsletter settings + - Contact form settings + - Stats page settings + - And more + +**Index HTML Editor:** + +- View and edit `index.html` content +- Meta tags, Open Graph, Twitter Cards, JSON-LD +- Download updated HTML file + +### Analytics + +- Real-time stats dashboard (clone of `/stats` page) +- Active visitors with per-page breakdown +- Total page views and unique visitors +- Views by page sorted by popularity +- Does not follow `siteConfig.statsPage` settings (always accessible in dashboard) + +### Sync commands + +**Sync Content Section:** + +- UI with buttons for all sync operations +- Development sync commands: + - `npm run sync` - Sync markdown content + - `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) + - `npm run sync:all` - Sync content + discovery files together +- Production sync commands: + - `npm run sync:prod` - Sync markdown content + - `npm run sync:discovery:prod` - Update discovery files + - `npm run sync:all:prod` - Sync content + discovery files together +- Server status indicator shows if sync server is online +- Copy and Execute buttons for each command +- Real-time terminal output when sync server is running +- Command modal shows full command output when sync server is offline +- Toast notifications for success/error feedback + +**Sync Server:** + +- Local HTTP server for executing commands from dashboard +- Start with `npm run sync-server` (runs on localhost:3001) +- Execute commands directly from dashboard with real-time output streaming +- Optional token authentication via `SYNC_TOKEN` environment variable +- Whitelisted commands only for security +- Health check endpoint for server availability detection +- Copy icons for `npm run sync-server` command in dashboard + +**Header Sync Buttons:** + +- Quick sync buttons in dashboard header (right side) +- `npm run sync:all` (dev) button +- `npm run sync:all:prod` (prod) button +- One-click sync for all content and discovery files +- Automatically use sync server when available, fallback to command modal + +### Dashboard features + +**Search:** + +- Search bar in header +- Search dashboard features, page titles, and post content +- Real-time results as you type + +**Theme and Font:** + +- Theme toggle (dark, light, tan, cloud) +- Font switcher (serif, sans, monospace) +- Preferences persist across sessions + +**Mobile Responsive:** + +- Fully responsive design +- Mobile-optimized layout +- Touch-friendly controls +- Collapsible sidebar on mobile + +**Toast Notifications:** + +- Success, error, info, and warning notifications +- Auto-dismiss after 4 seconds +- Theme-aware styling +- No browser default alerts + +**Command Modal:** + +- Shows sync command output +- Copy command to clipboard +- Close button to dismiss +- Theme-aware styling + +### Technical details + +**Database Architecture:** + +- Uses Convex queries for real-time data +- All mutations follow Convex best practices (idempotent, indexed queries) +- `source` field tracks content origin ("dashboard" or "sync") +- `by_source` index for efficient filtering by source + +**CMS Mutations (convex/cms.ts):** + +- `createPost` / `createPage` - Create with `source: "dashboard"` +- `updatePost` / `updatePage` - Update any post/page +- `deletePost` / `deletePage` - Delete any post/page +- `exportPostAsMarkdown` / `exportPageAsMarkdown` - Generate markdown with frontmatter + +**Import Action (convex/importAction.ts):** + +- Server-side Convex action using Firecrawl +- Scrapes URL, converts to markdown, saves to database +- Handles slug conflicts by appending timestamp + +**UI State:** + +- Frontmatter sidebar width persisted in localStorage +- Editor content persisted in localStorage +- Independent scrolling for editor and sidebar sections +- Preview uses ReactMarkdown with remark-gfm, remark-breaks, rehype-raw, rehype-sanitize +- Rich text editor uses Quill with Turndown/Showdown for conversion + +### Sync commands reference + +**Development:** + +- `npm run sync` - Sync markdown content to development Convex +- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data +- `npm run sync:all` - Run both content sync and discovery sync (development) + +**Production:** + +- `npm run sync:prod` - Sync markdown content to production Convex +- `npm run sync:discovery:prod` - Update discovery files with production data +- `npm run sync:all:prod` - Run both content sync and discovery sync (production) + +**Sync Server:** + +- `npm run sync-server` - Start local HTTP server for executing sync commands from dashboard UI + +**Content Import:** + +- `npm run import ` - Import external URL as markdown post (requires FIRECRAWL_API_KEY in .env.local) + +**Database Export:** + +- `npm run export:db` - Export dashboard posts/pages to content folders (development) +- `npm run export:db:prod` - Export dashboard posts/pages (production) + +**Note:** The dashboard provides a UI for these commands. When the sync server is running (`npm run sync-server`), you can execute commands directly from the dashboard with real-time output. Otherwise, the dashboard shows commands in a modal for copying to your terminal. + +### Environment variables + +**Convex Environment Variables:** + +| Variable | Description | +| ------------------- | -------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 (AI Agent) | +| `OPENAI_API_KEY` | Required for GPT-4o (AI Agent) | +| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | +| `FIRECRAWL_API_KEY` | Required for direct URL import | + +Set Convex environment variables with: + +```bash +npx convex env set VARIABLE_NAME value +``` + +**Local Environment Variables (.env.local):** + +| Variable | Description | +| ------------------- | ------------------------------------------ | +| `VITE_CONVEX_URL` | Your Convex deployment URL (auto-created) | +| `FIRECRAWL_API_KEY` | For CLI import command only | diff --git a/content/pages/docs-deployment.md b/content/pages/docs-deployment.md new file mode 100644 index 0000000..a83ee0a --- /dev/null +++ b/content/pages/docs-deployment.md @@ -0,0 +1,106 @@ +--- +title: "Deployment" +slug: "docs-deployment" +published: true +order: 6 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 6 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## 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) + authorName: v.optional(v.string()), // Author display name + authorImage: v.optional(v.string()), // Author avatar image URL + 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) + authorName: v.optional(v.string()), // Author display name + authorImage: v.optional(v.string()), // Author avatar image URL + 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 +- Use `npm run sync:all` or `npm run sync:all:prod` to sync content and update discovery files together +- 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+) diff --git a/content/pages/docs-frontmatter.md b/content/pages/docs-frontmatter.md new file mode 100644 index 0000000..3f61904 --- /dev/null +++ b/content/pages/docs-frontmatter.md @@ -0,0 +1,121 @@ +--- +title: "Frontmatter" +slug: "docs-frontmatter" +published: true +order: 3 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 3 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Frontmatter + +Frontmatter is the YAML metadata at the top of each markdown file between `---` markers. It controls how content is displayed, organized, and discovered. + +## Blog post fields + +| 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. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | +| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | +| `excerpt` | No | Short text for card view | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this post (overrides siteConfig default) | +| `footer` | No | Per-post footer markdown (overrides `footer.md` and siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this post | +| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | +| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | +| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | +| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | +| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | +| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | +| `docsLanding` | No | Set `true` to use this post as the docs landing page (shown when navigating to `/docs`). | + +## Page fields + +| Field | Required | Description | +| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | Yes | Nav link text | +| `slug` | Yes | URL path | +| `published` | Yes | `true` to show | +| `order` | No | Nav order (lower = first) | +| `showInNav` | No | Show in navigation menu (default: `true`) | +| `excerpt` | No | Short text for card view | +| `image` | No | Thumbnail for featured card view | +| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this page (overrides siteConfig default) | +| `footer` | No | Per-page footer markdown (overrides `footer.md` and siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this page | +| `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment | +| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | +| `docsSectionGroup` | No | Group name for docs sidebar. Pages with the same group name appear together. | +| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | +| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | +| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | +| `docsLanding` | No | Set `true` to use this page as the docs landing page (shown when navigating to `/docs`). | + +## Common patterns + +### Hide pages from navigation + +Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. + +### Unlisted posts + +Set `unlisted: true` to hide a blog post from all listings while keeping it accessible via direct link. Unlisted posts are excluded from: blog listings (`/blog` page), featured sections (homepage), tag pages (`/tags/[tag]`), search results (Command+K), and related posts. The post remains accessible via direct URL (e.g., `/blog/post-slug`). Useful for draft posts, private content, or posts you want to share via direct link only. Note: `unlisted` only works for blog posts, not pages. + +### Show image at top + +Add `showImageAtTop: true` to display the `image` field at the top of the post/page above the header. Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails. + +### Image lightbox + +Images in blog posts and pages automatically open in a full-screen lightbox when clicked (if enabled in `siteConfig.imageLightbox.enabled`). This allows readers to view images at full size. The lightbox can be closed by clicking outside the image, pressing Escape, or clicking the close button. + +### Text alignment + +Use `textAlign` field to control text alignment for page content. Options: `"left"` (default), `"center"`, or `"right"`. Used by `home.md` to control home intro alignment. + +### Docs section + +To add content to the docs sidebar: + +1. Add `docsSection: true` to frontmatter +2. Optionally set `docsSectionGroup` to group related content +3. Use `docsSectionOrder` to control order within groups +4. Use `docsSectionGroupOrder` to control group order +5. Add `docsSectionGroupIcon` for visual icons (Phosphor icons) + +### Docs landing page + +Set `docsLanding: true` on one post or page to make it the docs landing page. This content displays when navigating to `/docs`. diff --git a/content/pages/docs-search.md b/content/pages/docs-search.md new file mode 100644 index 0000000..711cf4d --- /dev/null +++ b/content/pages/docs-search.md @@ -0,0 +1,232 @@ +--- +title: "Search" +slug: "docs-search" +published: true +order: 2 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 3 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Keyword Search + +Keyword search matches exact words using Convex full-text search. Results update instantly as you type. + +For meaning-based search that finds conceptually similar content, see [Semantic Search](/docs-semantic-search). + +--- + +### Keyboard shortcuts + +Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to open search. Click the magnifying glass icon works too. + +| Key | Action | +| --- | ------ | +| `Cmd+K` / `Ctrl+K` | Open/close search | +| `Tab` | Switch between Keyword and Semantic modes | +| `↑` `↓` | Navigate results | +| `Enter` | Select result | +| `Esc` | Close modal | + +### How keyword search works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ KEYWORD SEARCH FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + + ┌──────────┐ ┌─────────────┐ ┌──────────────────┐ + │ Cmd+K │───▶│ SearchModal │───▶│ Convex Query │ + └──────────┘ └─────────────┘ └────────┬─────────┘ + │ + ┌──────────────────────┴──────────────────────┐ + ▼ ▼ + ┌────────────────┐ ┌────────────────┐ + │ search_title │ │ search_content │ + │ (index) │ │ (index) │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └──────────────────┬─────────────────────────┘ + ▼ + ┌─────────────────────┐ + │ Dedupe + Rank │ + │ (title matches first)│ + └──────────┬──────────┘ + ▼ + ┌─────────────────────┐ + │ Search Results │ + │ (max 15) │ + └──────────┬──────────┘ + ▼ + ┌─────────────────────┐ + │ Navigate + ?q=term │ + └──────────┬──────────┘ + ▼ + ┌─────────────────────┐ + │ Highlight matches │ + │ + scroll to first │ + └─────────────────────┘ +``` + +1. User presses `Cmd+K` to open SearchModal +2. SearchModal sends reactive query to Convex as user types +3. Convex searches both title and content indexes in parallel +4. Results are deduplicated (same post can match both indexes) +5. Results ranked with title matches first, limited to 15 +6. User selects result, navigates with `?q=searchterm` param +7. Destination page highlights all matches and scrolls to first + +### Why keyword search + +Keyword search is the default because it's: + +- **Instant** - Results return in milliseconds, no API calls +- **Free** - No external services, no per-query costs +- **Reactive** - Updates in real-time as you type +- **Highlightable** - Matches exact words, so results can be highlighted on the destination page + +Use keyword search when you know the exact terms, code snippets, or commands you're looking for. + +### How search indexes work + +Search indexes are defined in `convex/schema.ts` on the posts and pages tables: + +```typescript +posts: defineTable({ + title: v.string(), + content: v.string(), + published: v.boolean(), + // ... other fields +}) + .searchIndex("search_content", { + searchField: "content", + filterFields: ["published"], + }) + .searchIndex("search_title", { + searchField: "title", + filterFields: ["published"], + }), +``` + +Each table has two search indexes: + +- `search_title` - Searches the title field +- `search_content` - Searches the full markdown content + +The `filterFields: ["published"]` allows filtering to only published content without a separate query. + +### Search query implementation + +The search query in `convex/search.ts` searches both titles and content, then deduplicates and ranks results: + +```typescript +// Search posts by title +const postsByTitle = await ctx.db + .query("posts") + .withSearchIndex("search_title", (q) => + q.search("title", args.query).eq("published", true) + ) + .take(10); + +// Search posts by content +const postsByContent = await ctx.db + .query("posts") + .withSearchIndex("search_content", (q) => + q.search("content", args.query).eq("published", true) + ) + .take(10); +``` + +Key features: + +- **Dual search** - Searches both title and content indexes +- **Filter by published** - Only returns published content +- **Deduplication** - Removes duplicates when a post matches both title and content +- **Ranking** - Title matches sort before content-only matches +- **Snippets** - Generates context snippets around the search term +- **Unlisted filtering** - Excludes posts with `unlisted: true` + +### Frontend search modal + +The `SearchModal` component (`src/components/SearchModal.tsx`) provides the UI: + +```typescript +// Reactive search query - updates as you type +const results = useQuery( + api.search.search, + searchQuery.trim() ? { query: searchQuery } : "skip" +); +``` + +The `"skip"` parameter tells Convex to skip the query when the search field is empty, avoiding unnecessary database calls. + +### Search result highlighting + +When you click a search result, the app navigates to the page with a `?q=` parameter. The `useSearchHighlighting` hook (`src/hooks/useSearchHighlighting.ts`) then: + +1. Waits for page content to load +2. Finds all occurrences of the search term +3. Wraps matches in `` tags with highlight styling +4. Scrolls to the first match +5. Highlights pulse, then fade after 4 seconds +6. Press `Esc` to clear highlights manually + +### Files involved + +| File | Purpose | +| ---- | ------- | +| `convex/schema.ts` | Search index definitions | +| `convex/search.ts` | Search query with deduplication and snippets | +| `src/components/SearchModal.tsx` | Search UI with keyboard navigation | +| `src/components/Layout.tsx` | Keyboard shortcut handler (Cmd+K) | +| `src/hooks/useSearchHighlighting.ts` | Result highlighting and scroll-to-match | + +### Adding search to new tables + +To add search to a new table: + +1. Add a search index in `convex/schema.ts`: + +```typescript +myTable: defineTable({ + title: v.string(), + body: v.string(), + status: v.string(), +}) + .searchIndex("search_body", { + searchField: "body", + filterFields: ["status"], + }), +``` + +2. Create a search query in a Convex function: + +```typescript +const results = await ctx.db + .query("myTable") + .withSearchIndex("search_body", (q) => + q.search("body", searchTerm).eq("status", "published") + ) + .take(10); +``` + +3. Run `npx convex dev` to deploy the new index + +### Limitations + +- Search indexes only work on string fields +- One `searchField` per index (create multiple indexes for multiple fields) +- Filter fields support equality only (not ranges or inequalities) +- Results are ranked by relevance, not by date or other fields +- Maximum 10 results per `.take()` call (paginate for more) + +### Resources + +- [Convex Full-Text Search](https://docs.convex.dev/search/text-search) +- [Search Index API](https://docs.convex.dev/api/classes/server.TableDefinition#searchindex) +- [Semantic Search](/docs-semantic-search) - Vector-based search for finding similar content diff --git a/content/pages/docs-semantic-search.md b/content/pages/docs-semantic-search.md new file mode 100644 index 0000000..c68134e --- /dev/null +++ b/content/pages/docs-semantic-search.md @@ -0,0 +1,134 @@ +--- +title: "Semantic Search" +slug: "docs-semantic-search" +published: true +order: 2 +showInNav: false +layout: "sidebar" +rightSidebar: true +showFooter: true +docsSection: true +docsSectionOrder: 4 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Semantic Search + +Semantic search finds content by meaning, not exact words. Ask questions naturally and find conceptually related content. + +Press `Cmd+K` then `Tab` to switch to Semantic mode. For exact word matching, see [Keyword Search](/docs-search). + +--- + +### When to use each mode + +| Use case | Mode | +|----------|------| +| "authentication error" (exact term) | Keyword | +| "login problems" (conceptual) | Semantic | +| Find specific code or commands | Keyword | +| "how do I deploy?" (question) | Semantic | +| Need matches highlighted on page | Keyword | +| Not sure of exact terminology | Semantic | + +### How semantic search works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SEMANTIC SEARCH FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐ + │ User query: │───▶│ OpenAI API │───▶│ Query embedding │ + │ "how to │ │ text-embedding- │ │ [0.12, -0.45, │ + │ deploy" │ │ ada-002 │ │ 0.78, ...] │ + └──────────────┘ └─────────────────┘ └────────┬─────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Convex vectorSearch │ + │ Compare to stored │ + │ post/page embeddings│ + └──────────┬──────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Results sorted by │ + │ similarity score │ + │ (0-100%) │ + └─────────────────────┘ +``` + +1. Your query is converted to a vector (1536 numbers) using OpenAI's embedding model +2. Convex compares this vector to stored embeddings for all posts and pages +3. Results are ranked by similarity score (higher = more similar meaning) +4. Top 15 results returned + +### Technical comparison + +| Aspect | Keyword | Semantic | +|--------|---------|----------| +| Speed | Instant | ~300ms | +| Cost | Free | ~$0.0001/query | +| Highlighting | Yes | No | +| API required | No | OpenAI | + +### Configuration + +Semantic search requires an OpenAI API key: + +```bash +npx convex env set OPENAI_API_KEY sk-your-key-here +``` + +If the key is not configured: +- Semantic search returns empty results +- Keyword search continues to work normally +- Sync script skips embedding generation + +### How embeddings are generated + +When you run `npm run sync`: + +1. Content syncs to Convex (posts and pages) +2. Script checks for posts/pages without embeddings +3. For each, combines title + content into text +4. Calls OpenAI to generate 1536-dimension embedding +5. Stores embedding in Convex database + +Embeddings are generated once per post/page. If content changes, a new embedding is generated on the next sync. + +### Files involved + +| File | Purpose | +| ---- | ------- | +| `convex/schema.ts` | `embedding` field and `vectorIndex` on posts/pages | +| `convex/embeddings.ts` | Embedding generation actions | +| `convex/embeddingsQueries.ts` | Queries for posts/pages without embeddings | +| `convex/semanticSearch.ts` | Vector search action | +| `convex/semanticSearchQueries.ts` | Queries for hydrating search results | +| `src/components/SearchModal.tsx` | Mode toggle (Tab to switch) | +| `scripts/sync-posts.ts` | Triggers embedding generation after sync | + +### Limitations + +- **No highlighting**: Semantic search finds meaning, not exact words, so matches can't be highlighted +- **API cost**: Each search query costs ~$0.0001 (embedding generation) +- **Latency**: ~300ms vs instant for keyword search (API round-trip) +- **Requires OpenAI key**: Won't work without `OPENAI_API_KEY` configured +- **Token limit**: Content is truncated to ~8000 characters for embedding + +### Similarity scores + +Results show a percentage score (0-100%): +- **90%+**: Very similar meaning +- **70-90%**: Related content +- **50-70%**: Loosely related +- **<50%**: Weak match (may not be relevant) + +### Resources + +- [Convex Vector Search](https://docs.convex.dev/search/vector-search) +- [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings) +- [Keyword Search](/docs-search) - Full-text search documentation diff --git a/content/pages/docs.md b/content/pages/docs.md index 821e7b5..46b9e75 100644 --- a/content/pages/docs.md +++ b/content/pages/docs.md @@ -15,7 +15,7 @@ docsSectionGroupIcon: "Rocket" docsLanding: true --- -## Getting Started +## Getting started Reference documentation for setting up, customizing, and deploying this markdown framework. @@ -87,789 +87,6 @@ markdown-site/ └── netlify.toml # Deployment config ``` -## Content - -**Markdown examples:** For complete markdown syntax examples including code blocks, tables, lists, links, images, collapsible sections, and all formatting options, see [Writing Markdown with Code Examples](/markdown-with-code-examples). That post includes copy-paste examples for every markdown feature. - -### 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. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | -| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | -| `excerpt` | No | Short text for card view | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | -| `showFooter` | No | Show footer on this post (overrides siteConfig default) | -| `footer` | No | Per-post footer markdown (overrides `footer.md` and siteConfig.defaultContent) | -| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) | -| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | -| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | -| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | -| `contactForm` | No | Enable contact form on this post | -| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | -| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | -| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | -| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | -| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | -| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | -| `docsLanding` | No | Set `true` to use this post as the docs landing page (shown when navigating to `/docs`). | -| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) | - -### Static pages - -Create files in `content/pages/` with frontmatter: - -```markdown ---- -title: "Page Title" -slug: "url-path" -published: true -order: 1 ---- - -Content here... -``` - -### Frontmatter options - -| Field | Required | Description | -| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `title` | Yes | Nav link text | -| `slug` | Yes | URL path | -| `published` | Yes | `true` to show | -| `order` | No | Nav order (lower = first) | -| `showInNav` | No | Show in navigation menu (default: `true`) | -| `excerpt` | No | Short text for card view | -| `image` | No | Thumbnail for featured card view | -| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | -| `showFooter` | No | Show footer on this page (overrides siteConfig default) | -| `footer` | No | Per-page footer markdown (overrides `footer.md` and siteConfig.defaultContent) | -| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) | -| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | -| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | -| `contactForm` | No | Enable contact form on this page | -| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the page above the header (default: `false`) | -| `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment | -| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | -| `docsSectionGroup` | No | Group name for docs sidebar. Pages with the same group name appear together. | -| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | -| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | -| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | -| `docsLanding` | No | Set `true` to use this page as the docs landing page (shown when navigating to `/docs`). | - -**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. - -**Unlisted posts:** Set `unlisted: true` to hide a blog post from all listings while keeping it accessible via direct link. Unlisted posts are excluded from: blog listings (`/blog` page), featured sections (homepage), tag pages (`/tags/[tag]`), search results (Command+K), and related posts. The post remains accessible via direct URL (e.g., `/blog/post-slug`). Useful for draft posts, private content, or posts you want to share via direct link only. Note: `unlisted` only works for blog posts, not pages. - -**Show image at top:** Add `showImageAtTop: true` to display the `image` field at the top of the post/page above the header. Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails. - -**Image lightbox:** Images in blog posts and pages automatically open in a full-screen lightbox when clicked (if enabled in `siteConfig.imageLightbox.enabled`). This allows readers to view images at full size. The lightbox can be closed by clicking outside the image, pressing Escape, or clicking the close button. - -**Text alignment:** Use `textAlign` field to control text alignment for page content. Options: `"left"` (default), `"center"`, or `"right"`. Used by `home.md` to control home intro alignment. - -### Home intro content - -The homepage intro text can be synced from markdown via `content/pages/home.md` (slug: `home-intro`). This allows you to update homepage text without redeploying. - -**Create home intro:** - -1. Create `content/pages/home.md`: - -```markdown ---- -title: "Home Intro" -slug: "home-intro" -published: true -showInNav: false -order: -1 -textAlign: "left" ---- - -Your homepage intro text here. - -## Features - -**Feature one** — Description here. - -**Feature two** — Description here. -``` - -2. Run `npm run sync` to sync to Convex - -3. Content appears on homepage instantly (no rebuild needed) - -**Blog heading styles:** Headings (h1-h6) in home intro content use the same styling as blog posts (`blog-h1` through `blog-h6` classes). Each heading gets an automatic ID and a clickable anchor link (#) that appears on hover. Lists, blockquotes, horizontal rules, and links also use blog styling classes for consistent typography. - -**Fallback:** If `home-intro` page is not found, the homepage falls back to `siteConfig.bio` text. - -### Footer content - -The footer content can be synced from markdown via `content/pages/footer.md` (slug: `footer`). This allows you to update footer text without touching code. - -**Create footer content:** - -1. Create `content/pages/footer.md`: - -```markdown ---- -title: "Footer" -slug: "footer" -published: true -showInNav: false -order: -1 ---- - -Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). - -Created by [Your Name](https://x.com/yourhandle). Follow on [Twitter/X](https://x.com/yourhandle) and [GitHub](https://github.com/yourusername). -``` - -2. Run `npm run sync` to sync to Convex - -3. Footer content appears on homepage, blog page, and all posts/pages instantly (no rebuild needed) - -**Markdown support:** Footer content supports full markdown including links, paragraphs, line breaks, and images. External links automatically open in new tabs. - -**Fallback:** If `footer` page is not found, the footer falls back to `siteConfig.footer.defaultContent`. - -**Priority order:** Per-post/page frontmatter `footer:` field (custom override) > synced footer.md content > siteConfig.footer.defaultContent. - -**Relationship with siteConfig:** The `content/pages/footer.md` page takes priority over `siteConfig.footer.defaultContent` when present. Use the markdown page for dynamic content that changes frequently, or keep using siteConfig for static footer content. - -### Sidebar layout - -Posts and pages can use a docs-style layout with a table of contents sidebar. Add `layout: "sidebar"` to the frontmatter: - -```markdown ---- -title: "Documentation" -slug: "docs" -published: true -layout: "sidebar" ---- - -# Introduction - -## Section One - -### Subsection - -## Section Two -``` - -**Features:** - -- Left sidebar displays table of contents extracted from H1, H2, H3 headings -- Two-column layout: 220px sidebar + flexible content area -- Sidebar only appears if headings exist in the content -- Active heading highlighting as you scroll -- Smooth scroll navigation when clicking TOC links -- Mobile responsive: stacks to single column below 1024px -- Works for both blog posts and static pages - -The sidebar extracts headings automatically from your markdown content. No manual TOC needed. - -### Right sidebar - -When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. - -**Configuration:** - -Enable globally in `src/config/siteConfig.ts`: - -```typescript -rightSidebar: { - enabled: true, // Set to false to disable right sidebar globally - minWidth: 1135, // Minimum viewport width to show sidebar -}, -``` - -Control per post/page with frontmatter: - -```markdown ---- -title: "My Post" -rightSidebar: true # Enable right sidebar for this post ---- -``` - -**Features:** - -- Right sidebar appears at 1135px+ viewport width -- Contains CopyPageDropdown with all sharing options -- Three-column layout: left sidebar (TOC), main content, right sidebar -- CopyPageDropdown automatically moves from nav to right sidebar when enabled -- Hidden below 1135px breakpoint, CopyPageDropdown returns to nav -- Per-post/page control via `rightSidebar: true` frontmatter field -- Opt-in only: right sidebar only appears when explicitly enabled in frontmatter - -**Use cases:** - -- Keep CopyPageDropdown accessible on wide screens without cluttering the nav -- Provide quick access to sharing options while reading long content -- Works alongside left sidebar TOC for comprehensive navigation - -**Example for blog post:** - -```markdown ---- -title: "My Tutorial" -description: "A detailed guide" -date: "2025-01-20" -slug: "my-tutorial" -published: true -tags: ["tutorial"] -layout: "sidebar" ---- - -# Introduction - -## Getting Started - -### Prerequisites - -## Advanced Topics -``` - -### How frontmatter works - -Frontmatter is the YAML metadata at the top of each markdown file between `---` markers. Here is how it flows through the system: - -**Content directories:** - -- `content/blog/*.md` contains blog posts with frontmatter -- `content/pages/*.md` contains static pages with frontmatter - -**Processing flow:** - -1. Markdown files in `content/blog/` and `content/pages/` contain YAML frontmatter -2. `scripts/sync-posts.ts` uses `gray-matter` to parse frontmatter and validate required fields -3. Parsed data is sent to Convex mutations (`api.posts.syncPostsPublic`, `api.pages.syncPagesPublic`) -4. `convex/schema.ts` defines the database structure for storing the data - -**Adding a new frontmatter field:** - -To add a custom frontmatter field, update these files: - -1. The interface in `scripts/sync-posts.ts` (`PostFrontmatter` or `PageFrontmatter`) -2. The parsing logic in `parseMarkdownFile()` or `parsePageFile()` functions -3. The schema in `convex/schema.ts` -4. The sync mutation in `convex/posts.ts` or `convex/pages.ts` - -### Syncing content - -**Development:** - -```bash -npm run sync # Sync markdown content -npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt) -npm run sync:all # Sync content + discovery files together -``` - -**Production:** - -```bash -npm run sync:prod # Sync markdown content -npm run sync:discovery:prod # Update discovery files -npm run sync:all:prod # Sync content + discovery files together -``` - -**Sync everything together:** - -```bash -npm run sync:all # Development: content + discovery -npm run sync:all:prod # Production: content + discovery -``` - -### 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) | -| Site config changes | `npm run sync:discovery` | Updates discovery files | -| Import external URL | `npm run import` then sync | Instant (no rebuild) | -| Images in `public/images/` | Git commit + push | Requires rebuild | -| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | -| Logo gallery config | Redeploy | Requires rebuild | -| React components/styles | Redeploy | Requires rebuild | - -**Markdown content** syncs instantly to Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild. - -## Configuration - -### Fork configuration - -After forking, you have two options to configure your site: - -**Option 1: Automated (Recommended)** - -```bash -cp fork-config.json.example fork-config.json -# Edit fork-config.json with your site information -npm run configure -``` - -This updates all 11 configuration files in one command. See `FORK_CONFIG.md` for the full JSON schema and options. - -**Option 2: Manual** - -Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually. - -### Files updated by configuration - -| File | What to update | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `src/config/siteConfig.ts` | Site name, title, intro, bio, blog page, logo gallery, GitHub contributions, right sidebar configuration | -| `src/pages/Home.tsx` | Intro paragraph text, footer links | -| `convex/http.ts` | `SITE_URL`, `SITE_NAME`, description strings (3 locations) | -| `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, topics | -| `public/robots.txt` | Sitemap URL and header comment | -| `public/openapi.yaml` | API title, server URL, site name in examples | -| `public/.well-known/ai-plugin.json` | Site name, descriptions | -| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) | - -### Site title and description metadata - -These files contain the main site description text. Update them with your own tagline: - -| File | What to change | -| --------------------------------- | -------------------------------------------------------------- | -| `index.html` | meta description, og:description, twitter:description, JSON-LD | -| `README.md` | Main description at top of file | -| `src/config/siteConfig.ts` | name, title, and bio fields | -| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) | -| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) | -| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants | -| `public/llms.txt` | Header quote, Name, and Description fields | -| `public/openapi.yaml` | API title and example site name | -| `AGENTS.md` | Project overview section | -| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph | -| `content/pages/about.md` | excerpt field and opening paragraph | -| `content/pages/docs.md` | Opening description paragraph | - -**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. - -### Site settings - -Edit `src/config/siteConfig.ts`: - -```typescript -export default { - name: "Site Name", - title: "Tagline", - logo: "/images/logo.svg", // null to hide homepage logo - intro: "Introduction text...", - bio: "Bio text...", - - // Blog page configuration - blogPage: { - enabled: true, // Enable /blog route - showInNav: true, // Show in navigation - title: "Blog", // Nav link and page title - order: 0, // Nav order (lower = first) - }, - - // Hardcoded navigation items for React routes - hardcodedNavItems: [ - { - slug: "stats", - title: "Stats", - order: 10, - showInNav: true, // Set to false to hide from nav - }, - { - slug: "write", - title: "Write", - order: 20, - showInNav: true, - }, - ], - - // Inner page logo configuration - innerPageLogo: { - enabled: true, // Set to false to hide logo on inner pages - size: 28, // Logo height in pixels (keeps aspect ratio) - }, - - // Featured section - featuredViewMode: "list", // 'list' or 'cards' - showViewToggle: true, - - // Logo gallery (static grid or scrolling marquee) - logoGallery: { - enabled: true, // false to hide - images: [{ src: "/images/logos/logo.svg", href: "https://example.com" }], - position: "above-footer", - speed: 30, - title: "Built with", - scrolling: false, // false = static grid, true = scrolling marquee - maxItems: 4, // Number of logos when scrolling is false - }, - - links: { - docs: "/docs", - convex: "https://convex.dev", - }, -}; -``` - -**Logo configuration:** - -- `logo`: Homepage logo path (set to `null` to hide). Uses `public/images/logo.svg` by default. -- `innerPageLogo`: Logo shown on blog page, posts, and static pages. Desktop: top left. Mobile: top right. Set `enabled: false` to hide on inner pages while keeping homepage logo. - -**Navigation structure:** - -Navigation combines three sources sorted by `order`: - -1. Blog link (if `blogPage.enabled` and `blogPage.showInNav` are true) -2. Hardcoded nav items (React routes from `hardcodedNavItems`) -3. Markdown pages (from `content/pages/` with `showInNav: true`) - -All items sort by `order` (lower first), then alphabetically by title. - -### 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` or `npm run sync:all`. 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 -}; -``` - -### GitHub contributions graph - -Display your GitHub contribution activity on the homepage. Configure in `siteConfig`: - -```typescript -gitHubContributions: { - enabled: true, // Set to false to hide - username: "yourusername", // Your GitHub username - showYearNavigation: true, // Show arrows to navigate between years - linkToProfile: true, // Click graph to open GitHub profile - title: "GitHub Activity", // Optional title above the graph -}, -``` - -| Option | Description | -| -------------------- | -------------------------------------- | -| `enabled` | `true` to show, `false` to hide | -| `username` | Your GitHub username | -| `showYearNavigation` | Show prev/next year navigation | -| `linkToProfile` | Click graph to visit GitHub profile | -| `title` | Text above graph (`undefined` to hide) | - -Theme-aware colors match each site theme. Uses public API (no GitHub token required). - -### Visitor map - -Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. - -```typescript -visitorMap: { - enabled: true, // Set to false to hide - title: "Live Visitors", // Optional title above the map -}, -``` - -| Option | Description | -| --------- | ------------------------------------ | -| `enabled` | `true` to show, `false` to hide | -| `title` | Text above map (`undefined` to hide) | - -The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. - -### Logo gallery - -The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. - -```typescript -// In src/config/siteConfig.ts -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: "Built with", // undefined to hide - scrolling: false, // false = static grid, true = scrolling marquee - maxItems: 4, // Number of logos when scrolling is false -}, -``` - -| 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) | -| `scrolling` | `true` for infinite scroll, `false` for static grid | -| `maxItems` | Max logos to show when `scrolling` is `false` (default: 4) | - -**Display modes:** - -- `scrolling: true`: Infinite horizontal scroll with all logos -- `scrolling: false`: Static centered grid showing first `maxItems` logos - -**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. - -### Blog page - -The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`: - -```typescript -blogPage: { - enabled: true, // Enable /blog route - showInNav: true, // Show in navigation - title: "Blog", // Nav link and page title - order: 0, // Nav order (lower = first) - viewMode: "list", // Default view: "list" or "cards" - showViewToggle: true, // Show toggle button to switch views -}, -displayOnHomepage: true, // Show posts on homepage -``` - -| Option | Description | -| ------------------- | -------------------------------------- | -| `enabled` | Enable the `/blog` route | -| `showInNav` | Show Blog link in navigation | -| `title` | Text for nav link and page heading | -| `order` | Position in navigation (lower = first) | -| `viewMode` | Default view: `"list"` or `"cards"` | -| `showViewToggle` | Show toggle button to switch views | -| `displayOnHomepage` | Show post list on homepage | - -**View modes:** - -- **List view:** Year-grouped posts with titles, read time, and dates -- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata - -**Card view details:** - -Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile. - -**Display options:** - -- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false` -- Blog page only: `displayOnHomepage: false`, `blogPage.enabled: true` -- Both: `displayOnHomepage: true`, `blogPage.enabled: true` - -**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4. - -**View preference:** User's view mode choice is saved to localStorage and persists across page visits. - -### Scroll-to-top button - -A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`: - -```typescript -const scrollToTopConfig: Partial = { - 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`. - -Configure in `src/config/siteConfig.ts`: - -```typescript -export const siteConfig: SiteConfig = { - // ... other config - defaultTheme: "tan", -}; -``` - -### Font - -Configure the font in `src/config/siteConfig.ts`: - -```typescript -export const siteConfig: SiteConfig = { - // ... other config - fontFamily: "serif", // Options: "serif", "sans", or "monospace" -}; -``` - -Or edit `src/styles/global.css` directly: - -```css -body { - /* Sans-serif */ - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - - /* Serif (default) */ - font-family: "New York", ui-serif, Georgia, serif; - - /* Monospace */ - font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace; -} -``` - -Available options: `serif` (default), `sans`, or `monospace`. - -### Font Sizes - -All font sizes use CSS variables in `:root`. Customize by editing: - -```css -:root { - --font-size-base: 16px; - --font-size-sm: 13px; - --font-size-lg: 17px; - --font-size-blog-content: 17px; - --font-size-post-title: 32px; -} -``` - -Mobile sizes defined in `@media (max-width: 768px)` block. - -### 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 | - -**Images require git deploy.** Images are served as static files from your repository, not synced to Convex. After adding images to `public/images/`: - -1. Commit the image files to git -2. Push to GitHub -3. Wait for Netlify to rebuild - -The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes. - -**Adding images to posts:** You can add images using markdown syntax `![alt](src)` or HTML `` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices. - -**Logo options:** - -- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide. -- **Inner page logo:** Configured via `innerPageLogo` in `siteConfig.ts`. Shows on blog page, posts, and static pages. Desktop: top left corner. Mobile: top right corner (smaller). Set `enabled: false` to hide on inner pages while keeping homepage logo. - -## Tag Pages and Related Posts - -Tag pages are available at `/tags/[tag]` for each tag used in your posts. They display all posts with that tag in a list or card view with localStorage persistence for view mode preference. - -**Related posts:** Individual blog posts show up to 3 related posts in the footer based on shared tags. Posts are sorted by relevance (number of shared tags) then by date. Only appears on blog posts (not static pages). - -**Tag links:** Tags in post footers link to their respective tag archive pages. - ## 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. @@ -884,16 +101,6 @@ 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: @@ -920,9 +127,9 @@ Each post and page includes a share dropdown with options: **Download as SKILL.md:** Downloads the content formatted as an Anthropic Agent Skills file with metadata, triggers, and instructions sections. -## Homepage Post Limit +## Homepage post limit -Limit the number of posts shown on the homepage: +Limit the number of posts shown on the homepage. Configure in `src/config/siteConfig.ts`: ```typescript postsDisplay: { @@ -938,50 +145,6 @@ postsDisplay: { When posts are limited, an optional "read more" link appears below the list. Only shows when there are more posts than the limit. -## Blog Page Featured Layout - -Posts can be marked as featured on the blog page using the `blogFeatured` frontmatter field: - -```yaml ---- -title: "My Featured Post" -blogFeatured: true ---- -``` - -The first `blogFeatured` post displays as a hero card with landscape image, tags, date, title, excerpt, author info, and read more link. Remaining `blogFeatured` posts display in a 2-column featured row with excerpts. Regular (non-featured) posts display in a 3-column grid without excerpts. - -## Homepage Post Limit - -Limit the number of posts shown on the homepage: - -```typescript -postsDisplay: { - showOnHome: true, - homePostsLimit: 5, // Limit to 5 most recent posts (undefined = show all) - homePostsReadMore: { - enabled: true, - text: "Read more blog posts", - link: "/blog", - }, -}, -``` - -When posts are limited, an optional "read more" link appears below the list. Only shows when there are more posts than the limit. - -## Blog Page Featured Layout - -Posts can be marked as featured on the blog page using the `blogFeatured` frontmatter field: - -```yaml ---- -title: "My Featured Post" -blogFeatured: true ---- -``` - -The first `blogFeatured` post displays as a hero card with landscape image, tags, date, title, excerpt, author info, and read more link. Remaining `blogFeatured` posts display in a 2-column featured row with excerpts. Regular (non-featured) posts display in a 3-column grid without excerpts. - ## Real-time stats The `/stats` page displays real-time analytics: @@ -993,6 +156,15 @@ The `/stats` page displays real-time analytics: All stats update automatically via Convex subscriptions. +**Configuration:** Enable or disable in `src/config/siteConfig.ts`: + +```typescript +statsPage: { + enabled: true, // Enable /stats route + showInNav: false, // Hide from navigation (access via direct URL) +}, +``` + ## Newsletter Admin The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters. @@ -1012,9 +184,7 @@ The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subs - Retention rate - Detailed summary table -**Configuration:** - -Enable in `src/config/siteConfig.ts`: +**Configuration:** Enable in `src/config/siteConfig.ts`: ```typescript newsletterAdmin: { @@ -1067,247 +237,6 @@ npm run newsletter:send setup-guide The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results. -## Dashboard - -The Dashboard at `/dashboard` provides a centralized UI for managing content, configuring the site, and performing sync operations. It's designed for developers who fork the repository to set up and manage their markdown blog. - -**Access:** Navigate to `/dashboard` in your browser. The dashboard is not linked in the navigation by default (similar to Newsletter Admin pattern). - -**Authentication:** WorkOS authentication is optional. Configure it in `siteConfig.ts`: - -```typescript -dashboard: { - enabled: true, - requireAuth: false, // Set to true to require WorkOS authentication -}, -``` - -When `requireAuth` is `false`, the dashboard is open access. When `requireAuth` is `true` and WorkOS is configured, users must log in to access the dashboard. See [How to setup WorkOS](https://www.markdown.fast/how-to-setup-workos) for authentication setup. - -### Content Management - -**Posts and Pages List Views:** - -- View all posts and pages (published and unpublished) -- Filter by status: All, Published, Drafts -- Search by title or content -- Pagination with "First" and "Next" buttons -- Items per page selector (15, 25, 50, 100) - default: 15 -- Edit, view, and publish/unpublish options -- WordPress-style UI with date, edit, view, and publish controls - -**Post and Page Editor:** - -- Markdown editor with live preview -- Frontmatter sidebar on the right with all available fields -- Draggable/resizable frontmatter sidebar (200px-600px width) -- Independent scrolling for frontmatter sidebar -- Preview mode shows content as it appears on the live site -- Download markdown button to generate `.md` files -- Copy markdown to clipboard -- All frontmatter fields editable in sidebar -- Preview uses ReactMarkdown with proper styling - -**Write Post and Write Page:** - -- Full-screen writing interface -- Markdown editor with word/line/character counts -- Frontmatter reference panel -- Download markdown button for new content -- Content persists in localStorage -- Separate storage for post and page content - -### AI Agent - -The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat and Image Generation. - -**Chat Tab:** - -- Multi-model selector: Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash -- Per-session chat history stored in Convex -- Markdown rendering for AI responses -- Copy functionality for AI responses -- Lazy API key validation (errors only shown when user tries to use a specific model) - -**Image Tab:** - -- AI image generation with two models: - - Nano Banana (gemini-2.0-flash-exp-image-generation) - Experimental model - - Nano Banana Pro (imagen-3.0-generate-002) - Production model -- Aspect ratio selection: 1:1, 16:9, 9:16, 4:3, 3:4 -- Images stored in Convex storage with session tracking -- Gallery view of recent generated images - -**Environment Variables (Convex):** - -| Variable | Description | -| ------------------- | -------------------------------------------------- | -| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 | -| `OPENAI_API_KEY` | Required for GPT-4o | -| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | - -**Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model. - -### Newsletter Management - -All Newsletter Admin features integrated into the Dashboard: - -- **Subscribers:** View, search, filter, and delete subscribers -- **Send Newsletter:** Select a blog post to send as newsletter -- **Write Email:** Compose custom emails with markdown support -- **Recent Sends:** View last 10 newsletter sends (posts and custom emails) -- **Email Stats:** Dashboard with total emails, newsletters sent, active subscribers, retention rate - -All newsletter sections are full-width in the dashboard content area. - -### Content Import - -**Firecrawl Import:** - -- Import articles from external URLs using Firecrawl API -- Requires `FIRECRAWL_API_KEY` in `.env.local` -- Creates local markdown drafts in `content/blog/` -- Imported posts are drafts (`published: false`) by default -- Review, edit, set `published: true`, then sync - -### Site Configuration - -**Config Generator:** - -- UI to configure all settings in `src/config/siteConfig.ts` -- Generates downloadable `siteConfig.ts` file -- Hybrid approach: dashboard generates config, file-based config continues to work -- Includes all site configuration options: - - Site name, title, logo, bio, intro - - Blog page settings - - Featured section configuration - - Logo gallery settings - - GitHub contributions - - Footer and social footer - - Newsletter settings - - Contact form settings - - Stats page settings - - And more - -**Index HTML Editor:** - -- View and edit `index.html` content -- Meta tags, Open Graph, Twitter Cards, JSON-LD -- Download updated HTML file - -### Analytics - -- Real-time stats dashboard (clone of `/stats` page) -- Active visitors with per-page breakdown -- Total page views and unique visitors -- Views by page sorted by popularity -- Does not follow `siteConfig.statsPage` settings (always accessible in dashboard) - -### Sync Commands - -**Sync Content Section:** - -- UI with buttons for all sync operations -- Development sync commands: - - `npm run sync` - Sync markdown content - - `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) - - `npm run sync:all` - Sync content + discovery files together -- Production sync commands: - - `npm run sync:prod` - Sync markdown content - - `npm run sync:discovery:prod` - Update discovery files - - `npm run sync:all:prod` - Sync content + discovery files together -- Server status indicator shows if sync server is online -- Copy and Execute buttons for each command -- Real-time terminal output when sync server is running -- Command modal shows full command output when sync server is offline -- Toast notifications for success/error feedback - -**Sync Server:** - -- Local HTTP server for executing commands from dashboard -- Start with `npm run sync-server` (runs on localhost:3001) -- Execute commands directly from dashboard with real-time output streaming -- Optional token authentication via `SYNC_TOKEN` environment variable -- Whitelisted commands only for security -- Health check endpoint for server availability detection -- Copy icons for `npm run sync-server` command in dashboard - -**Header Sync Buttons:** - -- Quick sync buttons in dashboard header (right side) -- `npm run sync:all` (dev) button -- `npm run sync:all:prod` (prod) button -- One-click sync for all content and discovery files -- Automatically use sync server when available, fallback to command modal - -### Dashboard Features - -**Search:** - -- Search bar in header -- Search dashboard features, page titles, and post content -- Real-time results as you type - -**Theme and Font:** - -- Theme toggle (dark, light, tan, cloud) -- Font switcher (serif, sans, monospace) -- Preferences persist across sessions - -**Mobile Responsive:** - -- Fully responsive design -- Mobile-optimized layout -- Touch-friendly controls -- Collapsible sidebar on mobile - -**Toast Notifications:** - -- Success, error, info, and warning notifications -- Auto-dismiss after 4 seconds -- Theme-aware styling -- No browser default alerts - -**Command Modal:** - -- Shows sync command output -- Copy command to clipboard -- Close button to dismiss -- Theme-aware styling - -### Technical Details - -- Uses Convex queries for real-time data -- All mutations follow Convex best practices (idempotent, indexed queries) -- Frontmatter sidebar width persisted in localStorage -- Editor content persisted in localStorage -- Independent scrolling for editor and sidebar sections -- Preview uses ReactMarkdown with remark-gfm, remark-breaks, rehype-raw, rehype-sanitize - -### Sync Commands Reference - -**Development:** - -- `npm run sync` - Sync markdown content to development Convex -- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data -- `npm run sync:all` - Run both content sync and discovery sync (development) - -**Production:** - -- `npm run sync:prod` - Sync markdown content to production Convex -- `npm run sync:discovery:prod` - Update discovery files with production data -- `npm run sync:all:prod` - Run both content sync and discovery sync (production) - -**Sync Server:** - -- `npm run sync-server` - Start local HTTP server for executing sync commands from dashboard UI - -**Content Import:** - -- `npm run import ` - Import external URL as markdown post (requires FIRECRAWL_API_KEY) - -**Note:** The dashboard provides a UI for these commands. When the sync server is running (`npm run sync-server`), you can execute commands directly from the dashboard with real-time output. Otherwise, the dashboard shows commands in a modal for copying to your terminal. - ## API endpoints | Endpoint | Description | @@ -1397,13 +326,13 @@ All markdown features work with all four themes and are styled to match the site ## Import external content -Use Firecrawl to import articles from external URLs: +Use Firecrawl to import articles from external URLs. See [How to Use Firecrawl](/how-to-use-firecrawl) for detailed setup instructions. ```bash npm run import https://example.com/article ``` -Setup: +**Quick setup:** 1. Get an API key from firecrawl.dev 2. Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local` @@ -1419,95 +348,3 @@ The import command creates local markdown files only. It does not interact with 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) - authorName: v.optional(v.string()), // Author display name - authorImage: v.optional(v.string()), // Author avatar image URL - 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) - authorName: v.optional(v.string()), // Author display name - authorImage: v.optional(v.string()), // Author avatar image URL - 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 -- Use `npm run sync:all` or `npm run sync:all:prod` to sync content and update discovery files together -- 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+) diff --git a/content/pages/home.md b/content/pages/home.md index 6dff085..27441fb 100644 --- a/content/pages/home.md +++ b/content/pages/home.md @@ -30,3 +30,5 @@ agents. --> **Real-time team sync** — Multiple developers run npm run sync from different machines. **Sync Commands** - Sync discovery commands to update AGENTS.md, CLAUDE.md, and llms.txt + +**Semantic search** - Find content by meaning, not just keywords, using vector embeddings. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 2ea446d..53e06e5 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -11,16 +11,22 @@ import type * as aiChatActions from "../aiChatActions.js"; import type * as aiChats from "../aiChats.js"; import type * as aiImageGeneration from "../aiImageGeneration.js"; +import type * as cms from "../cms.js"; import type * as contact from "../contact.js"; import type * as contactActions from "../contactActions.js"; import type * as crons from "../crons.js"; +import type * as embeddings from "../embeddings.js"; +import type * as embeddingsQueries from "../embeddingsQueries.js"; import type * as http from "../http.js"; +import type * as importAction from "../importAction.js"; import type * as newsletter from "../newsletter.js"; import type * as newsletterActions from "../newsletterActions.js"; import type * as pages from "../pages.js"; import type * as posts from "../posts.js"; import type * as rss from "../rss.js"; import type * as search from "../search.js"; +import type * as semanticSearch from "../semanticSearch.js"; +import type * as semanticSearchQueries from "../semanticSearchQueries.js"; import type * as stats from "../stats.js"; import type { @@ -33,16 +39,22 @@ declare const fullApi: ApiFromModules<{ aiChatActions: typeof aiChatActions; aiChats: typeof aiChats; aiImageGeneration: typeof aiImageGeneration; + cms: typeof cms; contact: typeof contact; contactActions: typeof contactActions; crons: typeof crons; + embeddings: typeof embeddings; + embeddingsQueries: typeof embeddingsQueries; http: typeof http; + importAction: typeof importAction; newsletter: typeof newsletter; newsletterActions: typeof newsletterActions; pages: typeof pages; posts: typeof posts; rss: typeof rss; search: typeof search; + semanticSearch: typeof semanticSearch; + semanticSearchQueries: typeof semanticSearchQueries; stats: typeof stats; }>; diff --git a/convex/cms.ts b/convex/cms.ts new file mode 100644 index 0000000..70f8ef0 --- /dev/null +++ b/convex/cms.ts @@ -0,0 +1,412 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +// Shared validator for post data +const postDataValidator = v.object({ + slug: v.string(), + title: v.string(), + description: v.string(), + content: v.string(), + date: v.string(), + published: v.boolean(), + tags: v.array(v.string()), + readTime: v.optional(v.string()), + image: v.optional(v.string()), + showImageAtTop: v.optional(v.boolean()), + excerpt: v.optional(v.string()), + featured: v.optional(v.boolean()), + featuredOrder: v.optional(v.number()), + authorName: v.optional(v.string()), + authorImage: v.optional(v.string()), + layout: v.optional(v.string()), + rightSidebar: v.optional(v.boolean()), + showFooter: v.optional(v.boolean()), + footer: v.optional(v.string()), + showSocialFooter: v.optional(v.boolean()), + aiChat: v.optional(v.boolean()), + blogFeatured: v.optional(v.boolean()), + newsletter: v.optional(v.boolean()), + contactForm: v.optional(v.boolean()), + unlisted: v.optional(v.boolean()), + docsSection: v.optional(v.boolean()), + docsSectionGroup: v.optional(v.string()), + docsSectionOrder: v.optional(v.number()), + docsSectionGroupOrder: v.optional(v.number()), + docsSectionGroupIcon: v.optional(v.string()), + docsLanding: v.optional(v.boolean()), +}); + +// Shared validator for page data +const pageDataValidator = v.object({ + slug: v.string(), + title: v.string(), + content: v.string(), + published: v.boolean(), + order: v.optional(v.number()), + showInNav: v.optional(v.boolean()), + excerpt: v.optional(v.string()), + image: v.optional(v.string()), + showImageAtTop: v.optional(v.boolean()), + featured: v.optional(v.boolean()), + featuredOrder: v.optional(v.number()), + authorName: v.optional(v.string()), + authorImage: v.optional(v.string()), + layout: v.optional(v.string()), + rightSidebar: v.optional(v.boolean()), + showFooter: v.optional(v.boolean()), + footer: v.optional(v.string()), + showSocialFooter: v.optional(v.boolean()), + aiChat: v.optional(v.boolean()), + contactForm: v.optional(v.boolean()), + newsletter: v.optional(v.boolean()), + textAlign: v.optional(v.string()), + docsSection: v.optional(v.boolean()), + docsSectionGroup: v.optional(v.string()), + docsSectionOrder: v.optional(v.number()), + docsSectionGroupOrder: v.optional(v.number()), + docsSectionGroupIcon: v.optional(v.string()), + docsLanding: v.optional(v.boolean()), +}); + +// Create a new post via dashboard +export const createPost = mutation({ + args: { post: postDataValidator }, + returns: v.id("posts"), + handler: async (ctx, args) => { + // Check if slug already exists + const existing = await ctx.db + .query("posts") + .withIndex("by_slug", (q) => q.eq("slug", args.post.slug)) + .first(); + + if (existing) { + throw new Error(`Post with slug "${args.post.slug}" already exists`); + } + + const postId = await ctx.db.insert("posts", { + ...args.post, + source: "dashboard", + lastSyncedAt: Date.now(), + }); + + return postId; + }, +}); + +// Update any post (dashboard or synced) +export const updatePost = mutation({ + args: { + id: v.id("posts"), + post: v.object({ + slug: v.optional(v.string()), + title: v.optional(v.string()), + description: v.optional(v.string()), + content: v.optional(v.string()), + date: v.optional(v.string()), + published: v.optional(v.boolean()), + tags: v.optional(v.array(v.string())), + readTime: v.optional(v.string()), + image: v.optional(v.string()), + showImageAtTop: v.optional(v.boolean()), + excerpt: v.optional(v.string()), + featured: v.optional(v.boolean()), + featuredOrder: v.optional(v.number()), + authorName: v.optional(v.string()), + authorImage: v.optional(v.string()), + layout: v.optional(v.string()), + rightSidebar: v.optional(v.boolean()), + showFooter: v.optional(v.boolean()), + footer: v.optional(v.string()), + showSocialFooter: v.optional(v.boolean()), + aiChat: v.optional(v.boolean()), + blogFeatured: v.optional(v.boolean()), + newsletter: v.optional(v.boolean()), + contactForm: v.optional(v.boolean()), + unlisted: v.optional(v.boolean()), + docsSection: v.optional(v.boolean()), + docsSectionGroup: v.optional(v.string()), + docsSectionOrder: v.optional(v.number()), + docsSectionGroupOrder: v.optional(v.number()), + docsSectionGroupIcon: v.optional(v.string()), + docsLanding: v.optional(v.boolean()), + }), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.get(args.id); + if (!existing) { + throw new Error("Post not found"); + } + + // If slug is being changed, check for conflicts + const newSlug = args.post.slug; + if (newSlug && newSlug !== existing.slug) { + const slugConflict = await ctx.db + .query("posts") + .withIndex("by_slug", (q) => q.eq("slug", newSlug)) + .first(); + if (slugConflict) { + throw new Error(`Post with slug "${newSlug}" already exists`); + } + } + + await ctx.db.patch(args.id, { + ...args.post, + lastSyncedAt: Date.now(), + }); + + return null; + }, +}); + +// Delete a post +export const deletePost = mutation({ + args: { id: v.id("posts") }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.get(args.id); + if (!existing) { + throw new Error("Post not found"); + } + + await ctx.db.delete(args.id); + return null; + }, +}); + +// Create a new page via dashboard +export const createPage = mutation({ + args: { page: pageDataValidator }, + returns: v.id("pages"), + handler: async (ctx, args) => { + // Check if slug already exists + const existing = await ctx.db + .query("pages") + .withIndex("by_slug", (q) => q.eq("slug", args.page.slug)) + .first(); + + if (existing) { + throw new Error(`Page with slug "${args.page.slug}" already exists`); + } + + const pageId = await ctx.db.insert("pages", { + ...args.page, + source: "dashboard", + lastSyncedAt: Date.now(), + }); + + return pageId; + }, +}); + +// Update any page (dashboard or synced) +export const updatePage = mutation({ + args: { + id: v.id("pages"), + page: v.object({ + slug: v.optional(v.string()), + title: v.optional(v.string()), + content: v.optional(v.string()), + published: v.optional(v.boolean()), + order: v.optional(v.number()), + showInNav: v.optional(v.boolean()), + excerpt: v.optional(v.string()), + image: v.optional(v.string()), + showImageAtTop: v.optional(v.boolean()), + featured: v.optional(v.boolean()), + featuredOrder: v.optional(v.number()), + authorName: v.optional(v.string()), + authorImage: v.optional(v.string()), + layout: v.optional(v.string()), + rightSidebar: v.optional(v.boolean()), + showFooter: v.optional(v.boolean()), + footer: v.optional(v.string()), + showSocialFooter: v.optional(v.boolean()), + aiChat: v.optional(v.boolean()), + contactForm: v.optional(v.boolean()), + newsletter: v.optional(v.boolean()), + textAlign: v.optional(v.string()), + docsSection: v.optional(v.boolean()), + docsSectionGroup: v.optional(v.string()), + docsSectionOrder: v.optional(v.number()), + docsSectionGroupOrder: v.optional(v.number()), + docsSectionGroupIcon: v.optional(v.string()), + docsLanding: v.optional(v.boolean()), + }), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.get(args.id); + if (!existing) { + throw new Error("Page not found"); + } + + // If slug is being changed, check for conflicts + const newSlug = args.page.slug; + if (newSlug && newSlug !== existing.slug) { + const slugConflict = await ctx.db + .query("pages") + .withIndex("by_slug", (q) => q.eq("slug", newSlug)) + .first(); + if (slugConflict) { + throw new Error(`Page with slug "${newSlug}" already exists`); + } + } + + await ctx.db.patch(args.id, { + ...args.page, + lastSyncedAt: Date.now(), + }); + + return null; + }, +}); + +// Delete a page +export const deletePage = mutation({ + args: { id: v.id("pages") }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db.get(args.id); + if (!existing) { + throw new Error("Page not found"); + } + + await ctx.db.delete(args.id); + return null; + }, +}); + +// Export post as markdown with frontmatter +export const exportPostAsMarkdown = query({ + args: { id: v.id("posts") }, + returns: v.string(), + handler: async (ctx, args) => { + const post = await ctx.db.get(args.id); + if (!post) { + throw new Error("Post not found"); + } + + // Build frontmatter + const frontmatter: string[] = ["---"]; + frontmatter.push(`title: "${post.title.replace(/"/g, '\\"')}"`); + frontmatter.push(`description: "${post.description.replace(/"/g, '\\"')}"`); + frontmatter.push(`date: "${post.date}"`); + frontmatter.push(`slug: "${post.slug}"`); + frontmatter.push(`published: ${post.published}`); + frontmatter.push(`tags: [${post.tags.map((t) => `"${t}"`).join(", ")}]`); + + // Add optional fields + if (post.readTime) frontmatter.push(`readTime: "${post.readTime}"`); + if (post.image) frontmatter.push(`image: "${post.image}"`); + if (post.showImageAtTop !== undefined) + frontmatter.push(`showImageAtTop: ${post.showImageAtTop}`); + if (post.excerpt) + frontmatter.push(`excerpt: "${post.excerpt.replace(/"/g, '\\"')}"`); + if (post.featured !== undefined) + frontmatter.push(`featured: ${post.featured}`); + if (post.featuredOrder !== undefined) + frontmatter.push(`featuredOrder: ${post.featuredOrder}`); + if (post.authorName) frontmatter.push(`authorName: "${post.authorName}"`); + if (post.authorImage) frontmatter.push(`authorImage: "${post.authorImage}"`); + if (post.layout) frontmatter.push(`layout: "${post.layout}"`); + if (post.rightSidebar !== undefined) + frontmatter.push(`rightSidebar: ${post.rightSidebar}`); + if (post.showFooter !== undefined) + frontmatter.push(`showFooter: ${post.showFooter}`); + if (post.footer) + frontmatter.push(`footer: "${post.footer.replace(/"/g, '\\"')}"`); + if (post.showSocialFooter !== undefined) + frontmatter.push(`showSocialFooter: ${post.showSocialFooter}`); + if (post.aiChat !== undefined) frontmatter.push(`aiChat: ${post.aiChat}`); + if (post.blogFeatured !== undefined) + frontmatter.push(`blogFeatured: ${post.blogFeatured}`); + if (post.newsletter !== undefined) + frontmatter.push(`newsletter: ${post.newsletter}`); + if (post.contactForm !== undefined) + frontmatter.push(`contactForm: ${post.contactForm}`); + if (post.unlisted !== undefined) + frontmatter.push(`unlisted: ${post.unlisted}`); + if (post.docsSection !== undefined) + frontmatter.push(`docsSection: ${post.docsSection}`); + if (post.docsSectionGroup) + frontmatter.push(`docsSectionGroup: "${post.docsSectionGroup}"`); + if (post.docsSectionOrder !== undefined) + frontmatter.push(`docsSectionOrder: ${post.docsSectionOrder}`); + if (post.docsSectionGroupOrder !== undefined) + frontmatter.push(`docsSectionGroupOrder: ${post.docsSectionGroupOrder}`); + if (post.docsSectionGroupIcon) + frontmatter.push(`docsSectionGroupIcon: "${post.docsSectionGroupIcon}"`); + if (post.docsLanding !== undefined) + frontmatter.push(`docsLanding: ${post.docsLanding}`); + + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${post.content}`; + }, +}); + +// Export page as markdown with frontmatter +export const exportPageAsMarkdown = query({ + args: { id: v.id("pages") }, + returns: v.string(), + handler: async (ctx, args) => { + const page = await ctx.db.get(args.id); + if (!page) { + throw new Error("Page not found"); + } + + // Build frontmatter + const frontmatter: string[] = ["---"]; + frontmatter.push(`title: "${page.title.replace(/"/g, '\\"')}"`); + frontmatter.push(`slug: "${page.slug}"`); + frontmatter.push(`published: ${page.published}`); + + // Add optional fields + if (page.order !== undefined) frontmatter.push(`order: ${page.order}`); + if (page.showInNav !== undefined) + frontmatter.push(`showInNav: ${page.showInNav}`); + if (page.excerpt) + frontmatter.push(`excerpt: "${page.excerpt.replace(/"/g, '\\"')}"`); + if (page.image) frontmatter.push(`image: "${page.image}"`); + if (page.showImageAtTop !== undefined) + frontmatter.push(`showImageAtTop: ${page.showImageAtTop}`); + if (page.featured !== undefined) + frontmatter.push(`featured: ${page.featured}`); + if (page.featuredOrder !== undefined) + frontmatter.push(`featuredOrder: ${page.featuredOrder}`); + if (page.authorName) frontmatter.push(`authorName: "${page.authorName}"`); + if (page.authorImage) frontmatter.push(`authorImage: "${page.authorImage}"`); + if (page.layout) frontmatter.push(`layout: "${page.layout}"`); + if (page.rightSidebar !== undefined) + frontmatter.push(`rightSidebar: ${page.rightSidebar}`); + if (page.showFooter !== undefined) + frontmatter.push(`showFooter: ${page.showFooter}`); + if (page.footer) + frontmatter.push(`footer: "${page.footer.replace(/"/g, '\\"')}"`); + if (page.showSocialFooter !== undefined) + frontmatter.push(`showSocialFooter: ${page.showSocialFooter}`); + if (page.aiChat !== undefined) frontmatter.push(`aiChat: ${page.aiChat}`); + if (page.contactForm !== undefined) + frontmatter.push(`contactForm: ${page.contactForm}`); + if (page.newsletter !== undefined) + frontmatter.push(`newsletter: ${page.newsletter}`); + if (page.textAlign) frontmatter.push(`textAlign: "${page.textAlign}"`); + if (page.docsSection !== undefined) + frontmatter.push(`docsSection: ${page.docsSection}`); + if (page.docsSectionGroup) + frontmatter.push(`docsSectionGroup: "${page.docsSectionGroup}"`); + if (page.docsSectionOrder !== undefined) + frontmatter.push(`docsSectionOrder: ${page.docsSectionOrder}`); + if (page.docsSectionGroupOrder !== undefined) + frontmatter.push(`docsSectionGroupOrder: ${page.docsSectionGroupOrder}`); + if (page.docsSectionGroupIcon) + frontmatter.push(`docsSectionGroupIcon: "${page.docsSectionGroupIcon}"`); + if (page.docsLanding !== undefined) + frontmatter.push(`docsLanding: ${page.docsLanding}`); + + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${page.content}`; + }, +}); diff --git a/convex/embeddings.ts b/convex/embeddings.ts new file mode 100644 index 0000000..7d38117 --- /dev/null +++ b/convex/embeddings.ts @@ -0,0 +1,161 @@ +"use node"; + +import { v } from "convex/values"; +import { action, internalAction } from "./_generated/server"; +import { internal } from "./_generated/api"; +import OpenAI from "openai"; + +// Generate embedding for text using OpenAI text-embedding-ada-002 +export const generateEmbedding = internalAction({ + args: { text: v.string() }, + returns: v.array(v.float64()), + handler: async (_ctx, { text }) => { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY not configured in Convex environment"); + } + + const openai = new OpenAI({ apiKey }); + const response = await openai.embeddings.create({ + model: "text-embedding-ada-002", + input: text.slice(0, 8000), // Truncate to stay within token limit + }); + + return response.data[0].embedding; + }, +}); + +// Internal action to generate embeddings for posts without them +export const generatePostEmbeddings = internalAction({ + args: {}, + returns: v.object({ processed: v.number() }), + handler: async (ctx) => { + const posts = await ctx.runQuery( + internal.embeddingsQueries.getPostsWithoutEmbeddings, + { limit: 10 } + ); + + let processed = 0; + for (const post of posts) { + try { + // Combine title and content for embedding + const textToEmbed = `${post.title}\n\n${post.content}`; + const embedding = await ctx.runAction(internal.embeddings.generateEmbedding, { + text: textToEmbed, + }); + await ctx.runMutation(internal.embeddingsQueries.savePostEmbedding, { + id: post._id, + embedding, + }); + processed++; + } catch (error) { + console.error(`Failed to generate embedding for post ${post._id}:`, error); + } + } + + return { processed }; + }, +}); + +// Internal action to generate embeddings for pages without them +export const generatePageEmbeddings = internalAction({ + args: {}, + returns: v.object({ processed: v.number() }), + handler: async (ctx) => { + const pages = await ctx.runQuery( + internal.embeddingsQueries.getPagesWithoutEmbeddings, + { limit: 10 } + ); + + let processed = 0; + for (const page of pages) { + try { + // Combine title and content for embedding + const textToEmbed = `${page.title}\n\n${page.content}`; + const embedding = await ctx.runAction(internal.embeddings.generateEmbedding, { + text: textToEmbed, + }); + await ctx.runMutation(internal.embeddingsQueries.savePageEmbedding, { + id: page._id, + embedding, + }); + processed++; + } catch (error) { + console.error(`Failed to generate embedding for page ${page._id}:`, error); + } + } + + return { processed }; + }, +}); + +// Public action to generate missing embeddings for all content +// Called from sync script or manually +export const generateMissingEmbeddings = action({ + args: {}, + returns: v.object({ + postsProcessed: v.number(), + pagesProcessed: v.number(), + skipped: v.boolean(), + }), + handler: async (ctx): Promise<{ + postsProcessed: number; + pagesProcessed: number; + skipped: boolean; + }> => { + // Check for API key first - gracefully skip if not configured + if (!process.env.OPENAI_API_KEY) { + console.log("OPENAI_API_KEY not set, skipping embedding generation"); + return { postsProcessed: 0, pagesProcessed: 0, skipped: true }; + } + + const postsResult: { processed: number } = await ctx.runAction( + internal.embeddings.generatePostEmbeddings, + {} + ); + const pagesResult: { processed: number } = await ctx.runAction( + internal.embeddings.generatePageEmbeddings, + {} + ); + + return { + postsProcessed: postsResult.processed, + pagesProcessed: pagesResult.processed, + skipped: false, + }; + }, +}); + +// Public action to regenerate embedding for a specific post +export const regeneratePostEmbedding = action({ + args: { slug: v.string() }, + returns: v.object({ success: v.boolean(), error: v.optional(v.string()) }), + handler: async (ctx, args) => { + if (!process.env.OPENAI_API_KEY) { + return { success: false, error: "OPENAI_API_KEY not configured" }; + } + + // Find the post by slug + const post = await ctx.runQuery(internal.embeddingsQueries.getPostBySlug, { + slug: args.slug, + }); + + if (!post) { + return { success: false, error: "Post not found" }; + } + + try { + const textToEmbed = `${post.title}\n\n${post.content}`; + const embedding = await ctx.runAction(internal.embeddings.generateEmbedding, { + text: textToEmbed, + }); + await ctx.runMutation(internal.embeddingsQueries.savePostEmbedding, { + id: post._id, + embedding, + }); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, +}); diff --git a/convex/embeddingsQueries.ts b/convex/embeddingsQueries.ts new file mode 100644 index 0000000..8bb30c7 --- /dev/null +++ b/convex/embeddingsQueries.ts @@ -0,0 +1,105 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery } from "./_generated/server"; + +// Internal query to get posts without embeddings +export const getPostsWithoutEmbeddings = internalQuery({ + args: { limit: v.number() }, + returns: v.array( + v.object({ + _id: v.id("posts"), + title: v.string(), + content: v.string(), + }) + ), + handler: async (ctx, args) => { + const posts = await ctx.db + .query("posts") + .withIndex("by_published", (q) => q.eq("published", true)) + .collect(); + + return posts + .filter((post) => !post.embedding) + .slice(0, args.limit) + .map((post) => ({ + _id: post._id, + title: post.title, + content: post.content, + })); + }, +}); + +// Internal query to get pages without embeddings +export const getPagesWithoutEmbeddings = internalQuery({ + args: { limit: v.number() }, + returns: v.array( + v.object({ + _id: v.id("pages"), + title: v.string(), + content: v.string(), + }) + ), + handler: async (ctx, args) => { + const pages = await ctx.db + .query("pages") + .withIndex("by_published", (q) => q.eq("published", true)) + .collect(); + + return pages + .filter((page) => !page.embedding) + .slice(0, args.limit) + .map((page) => ({ + _id: page._id, + title: page.title, + content: page.content, + })); + }, +}); + +// Internal mutation to save embedding for a post +export const savePostEmbedding = internalMutation({ + args: { + id: v.id("posts"), + embedding: v.array(v.float64()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { embedding: args.embedding }); + }, +}); + +// Internal mutation to save embedding for a page +export const savePageEmbedding = internalMutation({ + args: { + id: v.id("pages"), + embedding: v.array(v.float64()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { embedding: args.embedding }); + }, +}); + +// Internal query to get post by slug +export const getPostBySlug = internalQuery({ + args: { slug: v.string() }, + returns: v.union( + v.object({ + _id: v.id("posts"), + title: v.string(), + content: v.string(), + }), + v.null() + ), + handler: async (ctx, args) => { + const post = await ctx.db + .query("posts") + .withIndex("by_slug", (q) => q.eq("slug", args.slug)) + .first(); + + if (!post) return null; + + return { + _id: post._id, + title: post.title, + content: post.content, + }; + }, +}); diff --git a/convex/importAction.ts b/convex/importAction.ts new file mode 100644 index 0000000..9199565 --- /dev/null +++ b/convex/importAction.ts @@ -0,0 +1,145 @@ +"use node"; + +import { v } from "convex/values"; +import { action } from "./_generated/server"; +import { api } from "./_generated/api"; +import FirecrawlApp from "@mendable/firecrawl-js"; + +/** + * Generate a URL-safe slug from a title + */ +function generateSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 60); +} + +/** + * Clean up markdown content + */ +function cleanMarkdown(content: string): string { + return content.replace(/^\s+|\s+$/g, "").replace(/\n{3,}/g, "\n\n"); +} + +/** + * Calculate reading time from content + */ +function calculateReadTime(content: string): string { + const wordsPerMinute = 200; + const wordCount = content.split(/\s+/).length; + const minutes = Math.ceil(wordCount / wordsPerMinute); + return `${minutes} min read`; +} + +/** + * Import content from a URL using Firecrawl and save directly to database + */ +export const importFromUrl = action({ + args: { + url: v.string(), + published: v.optional(v.boolean()), + }, + returns: v.object({ + success: v.boolean(), + slug: v.optional(v.string()), + title: v.optional(v.string()), + error: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const apiKey = process.env.FIRECRAWL_API_KEY; + if (!apiKey) { + return { + success: false, + error: + "FIRECRAWL_API_KEY not configured. Add it to your Convex environment variables.", + }; + } + + try { + const firecrawl = new FirecrawlApp({ apiKey }); + const result = await firecrawl.scrapeUrl(args.url, { + formats: ["markdown"], + }); + + if (!result.success || !result.markdown) { + return { + success: false, + error: result.error || "Failed to scrape URL - no content returned", + }; + } + + const title = result.metadata?.title || "Imported Post"; + const description = result.metadata?.description || ""; + const content = cleanMarkdown(result.markdown); + const baseSlug = generateSlug(title); + const slug = baseSlug || `imported-${Date.now()}`; + const today = new Date().toISOString().split("T")[0]; + + // Add source attribution + let hostname: string; + try { + hostname = new URL(args.url).hostname; + } catch { + hostname = "external source"; + } + const contentWithAttribution = `${content}\n\n---\n\n*Originally published at [${hostname}](${args.url})*`; + + // Create post directly in database using the CMS mutation + try { + await ctx.runMutation(api.cms.createPost, { + post: { + slug, + title, + description, + content: contentWithAttribution, + date: today, + published: args.published ?? false, + tags: ["imported"], + readTime: calculateReadTime(content), + }, + }); + } catch (mutationError) { + // Handle slug conflict by adding timestamp + if ( + mutationError instanceof Error && + mutationError.message.includes("already exists") + ) { + const uniqueSlug = `${slug}-${Date.now()}`; + await ctx.runMutation(api.cms.createPost, { + post: { + slug: uniqueSlug, + title, + description, + content: contentWithAttribution, + date: today, + published: args.published ?? false, + tags: ["imported"], + readTime: calculateReadTime(content), + }, + }); + return { + success: true, + slug: uniqueSlug, + title, + }; + } + throw mutationError; + } + + return { + success: true, + slug, + title, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred", + }; + } + }, +}); diff --git a/convex/pages.ts b/convex/pages.ts index 2150739..6ebebf2 100644 --- a/convex/pages.ts +++ b/convex/pages.ts @@ -20,6 +20,7 @@ export const listAll = query({ featuredOrder: v.optional(v.number()), authorName: v.optional(v.string()), authorImage: v.optional(v.string()), + source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))), }), ), handler: async (ctx) => { @@ -48,6 +49,7 @@ export const listAll = query({ featuredOrder: page.featuredOrder, authorName: page.authorName, authorImage: page.authorImage, + source: page.source, })); }, }); @@ -317,6 +319,7 @@ export const getDocsLandingPage = query({ }); // Public mutation for syncing pages from markdown files +// Respects source field: only syncs pages where source !== "dashboard" export const syncPagesPublic = mutation({ args: { pages: v.array( @@ -356,11 +359,13 @@ export const syncPagesPublic = mutation({ created: v.number(), updated: v.number(), deleted: v.number(), + skipped: v.number(), }), handler: async (ctx, args) => { let created = 0; let updated = 0; let deleted = 0; + let skipped = 0; const now = Date.now(); const incomingSlugs = new Set(args.pages.map((p) => p.slug)); @@ -369,12 +374,17 @@ export const syncPagesPublic = mutation({ const existingPages = await ctx.db.query("pages").collect(); const existingBySlug = new Map(existingPages.map((p) => [p.slug, p])); - // Upsert incoming pages + // Upsert incoming pages (only if source !== "dashboard") for (const page of args.pages) { const existing = existingBySlug.get(page.slug); if (existing) { - // Update existing page + // Skip dashboard-created pages - don't overwrite them + if (existing.source === "dashboard") { + skipped++; + continue; + } + // Update existing sync page await ctx.db.patch(existing._id, { title: page.title, content: page.content, @@ -403,27 +413,29 @@ export const syncPagesPublic = mutation({ docsSectionGroupOrder: page.docsSectionGroupOrder, docsSectionGroupIcon: page.docsSectionGroupIcon, docsLanding: page.docsLanding, + source: "sync", lastSyncedAt: now, }); updated++; } else { - // Create new page + // Create new page with source: "sync" await ctx.db.insert("pages", { ...page, + source: "sync", lastSyncedAt: now, }); created++; } } - // Delete pages that no longer exist in the repo + // Delete pages that no longer exist in the repo (but not dashboard pages) for (const existing of existingPages) { - if (!incomingSlugs.has(existing.slug)) { + if (!incomingSlugs.has(existing.slug) && existing.source !== "dashboard") { await ctx.db.delete(existing._id); deleted++; } } - return { created, updated, deleted }; + return { created, updated, deleted, skipped }; }, }); diff --git a/convex/posts.ts b/convex/posts.ts index e93fdef..7ed20b3 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -22,6 +22,7 @@ export const listAll = query({ featuredOrder: v.optional(v.number()), authorName: v.optional(v.string()), authorImage: v.optional(v.string()), + source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))), }), ), handler: async (ctx) => { @@ -49,6 +50,7 @@ export const listAll = query({ featuredOrder: post.featuredOrder, authorName: post.authorName, authorImage: post.authorImage, + source: post.source, })); }, }); @@ -475,6 +477,7 @@ export const syncPosts = internalMutation({ }); // Public mutation wrapper for sync script (no auth required for build-time sync) +// Respects source field: only syncs posts where source !== "dashboard" export const syncPostsPublic = mutation({ args: { posts: v.array( @@ -517,11 +520,13 @@ export const syncPostsPublic = mutation({ created: v.number(), updated: v.number(), deleted: v.number(), + skipped: v.number(), }), handler: async (ctx, args) => { let created = 0; let updated = 0; let deleted = 0; + let skipped = 0; const now = Date.now(); const incomingSlugs = new Set(args.posts.map((p) => p.slug)); @@ -530,12 +535,17 @@ export const syncPostsPublic = mutation({ const existingPosts = await ctx.db.query("posts").collect(); const existingBySlug = new Map(existingPosts.map((p) => [p.slug, p])); - // Upsert incoming posts + // Upsert incoming posts (only if source !== "dashboard") for (const post of args.posts) { const existing = existingBySlug.get(post.slug); if (existing) { - // Update existing post + // Skip dashboard-created posts - don't overwrite them + if (existing.source === "dashboard") { + skipped++; + continue; + } + // Update existing sync post await ctx.db.patch(existing._id, { title: post.title, description: post.description, @@ -567,28 +577,30 @@ export const syncPostsPublic = mutation({ docsSectionGroupOrder: post.docsSectionGroupOrder, docsSectionGroupIcon: post.docsSectionGroupIcon, docsLanding: post.docsLanding, + source: "sync", lastSyncedAt: now, }); updated++; } else { - // Create new post + // Create new post with source: "sync" await ctx.db.insert("posts", { ...post, + source: "sync", lastSyncedAt: now, }); created++; } } - // Delete posts that no longer exist in the repo + // Delete posts that no longer exist in the repo (but not dashboard posts) for (const existing of existingPosts) { - if (!incomingSlugs.has(existing.slug)) { + if (!incomingSlugs.has(existing.slug) && existing.source !== "dashboard") { await ctx.db.delete(existing._id); deleted++; } } - return { created, updated, deleted }; + return { created, updated, deleted, skipped }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 93439c8..ee3e2c5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -36,6 +36,8 @@ export default defineSchema({ docsSectionGroupIcon: v.optional(v.string()), // Phosphor icon name for sidebar group docsLanding: v.optional(v.boolean()), // Use as /docs landing page lastSyncedAt: v.number(), + source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))), // Content source: "dashboard" (created in UI) or "sync" (from markdown files) + embedding: v.optional(v.array(v.float64())), // Vector embedding for semantic search (1536 dimensions, OpenAI text-embedding-ada-002) }) .index("by_slug", ["slug"]) .index("by_date", ["date"]) @@ -44,6 +46,7 @@ export default defineSchema({ .index("by_blogFeatured", ["blogFeatured"]) .index("by_authorName", ["authorName"]) .index("by_docsSection", ["docsSection"]) + .index("by_source", ["source"]) .searchIndex("search_content", { searchField: "content", filterFields: ["published"], @@ -51,6 +54,11 @@ export default defineSchema({ .searchIndex("search_title", { searchField: "title", filterFields: ["published"], + }) + .vectorIndex("by_embedding", { + vectorField: "embedding", + dimensions: 1536, + filterFields: ["published"], }), // Static pages (about, projects, contact, etc.) @@ -84,11 +92,14 @@ export default defineSchema({ docsSectionGroupIcon: v.optional(v.string()), // Phosphor icon name for sidebar group docsLanding: v.optional(v.boolean()), // Use as /docs landing page lastSyncedAt: v.number(), + source: v.optional(v.union(v.literal("dashboard"), v.literal("sync"))), // Content source: "dashboard" (created in UI) or "sync" (from markdown files) + embedding: v.optional(v.array(v.float64())), // Vector embedding for semantic search (1536 dimensions, OpenAI text-embedding-ada-002) }) .index("by_slug", ["slug"]) .index("by_published", ["published"]) .index("by_featured", ["featured"]) .index("by_docsSection", ["docsSection"]) + .index("by_source", ["source"]) .searchIndex("search_content", { searchField: "content", filterFields: ["published"], @@ -96,6 +107,11 @@ export default defineSchema({ .searchIndex("search_title", { searchField: "title", filterFields: ["published"], + }) + .vectorIndex("by_embedding", { + vectorField: "embedding", + dimensions: 1536, + filterFields: ["published"], }), // View counts for analytics diff --git a/convex/semanticSearch.ts b/convex/semanticSearch.ts new file mode 100644 index 0000000..2cf761b --- /dev/null +++ b/convex/semanticSearch.ts @@ -0,0 +1,156 @@ +"use node"; + +import { v } from "convex/values"; +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import OpenAI from "openai"; + +// Search result type matching existing search.ts format +const searchResultValidator = v.object({ + _id: v.string(), + type: v.union(v.literal("post"), v.literal("page")), + slug: v.string(), + title: v.string(), + description: v.optional(v.string()), + snippet: v.string(), + score: v.number(), // Similarity score from vector search +}); + +// Main semantic search action +export const semanticSearch = action({ + args: { query: v.string() }, + returns: v.array(searchResultValidator), + handler: async (ctx, args) => { + // Return empty for empty queries + if (!args.query.trim()) { + return []; + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + // Gracefully return empty if not configured + console.log("OPENAI_API_KEY not set, semantic search unavailable"); + return []; + } + + // Generate embedding for search query + const openai = new OpenAI({ apiKey }); + const embeddingResponse = await openai.embeddings.create({ + model: "text-embedding-ada-002", + input: args.query, + }); + const queryEmbedding = embeddingResponse.data[0].embedding; + + // Search posts using vector index + const postResults = await ctx.vectorSearch("posts", "by_embedding", { + vector: queryEmbedding, + limit: 10, + filter: (q) => q.eq("published", true), + }); + + // Search pages using vector index + const pageResults = await ctx.vectorSearch("pages", "by_embedding", { + vector: queryEmbedding, + limit: 10, + filter: (q) => q.eq("published", true), + }); + + // Fetch full document details + const posts: Array<{ + _id: string; + slug: string; + title: string; + description: string; + content: string; + unlisted?: boolean; + }> = await ctx.runQuery(internal.semanticSearchQueries.fetchPostsByIds, { + ids: postResults.map((r) => r._id), + }); + const pages: Array<{ + _id: string; + slug: string; + title: string; + content: string; + }> = await ctx.runQuery(internal.semanticSearchQueries.fetchPagesByIds, { + ids: pageResults.map((r) => r._id), + }); + + // Build results with scores + const results: Array<{ + _id: string; + type: "post" | "page"; + slug: string; + title: string; + description?: string; + snippet: string; + score: number; + }> = []; + + // Map posts with scores + for (const result of postResults) { + const post = posts.find((p) => p._id === result._id); + if (post) { + results.push({ + _id: String(post._id), + type: "post", + slug: post.slug, + title: post.title, + description: post.description, + snippet: createSnippet(post.content, 120), + score: result._score, + }); + } + } + + // Map pages with scores + for (const result of pageResults) { + const page = pages.find((p) => p._id === result._id); + if (page) { + results.push({ + _id: String(page._id), + type: "page", + slug: page.slug, + title: page.title, + snippet: createSnippet(page.content, 120), + score: result._score, + }); + } + } + + // Sort by score descending (higher = more similar) + results.sort((a, b) => b.score - a.score); + + // Limit to top 15 results + return results.slice(0, 15); + }, +}); + +// Check if semantic search is available (API key configured) +export const isSemanticSearchAvailable = action({ + args: {}, + returns: v.boolean(), + handler: async () => { + return !!process.env.OPENAI_API_KEY; + }, +}); + +// Helper to create snippet from content (same logic as search.ts) +function createSnippet(content: string, maxLength: number): string { + // Remove markdown syntax for cleaner snippets + const cleanContent = content + .replace(/#{1,6}\s/g, "") // Headers + .replace(/\*\*([^*]+)\*\*/g, "$1") // Bold + .replace(/\*([^*]+)\*/g, "$1") // Italic + .replace(/`([^`]+)`/g, "$1") // Inline code + .replace(/```[\s\S]*?```/g, "") // Code blocks + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Links + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "") // Images + .replace(/\n+/g, " ") // Newlines to spaces + .replace(/\s+/g, " ") // Multiple spaces to single + .trim(); + + if (cleanContent.length <= maxLength) { + return cleanContent; + } + return cleanContent.slice(0, maxLength) + "..."; +} diff --git a/convex/semanticSearchQueries.ts b/convex/semanticSearchQueries.ts new file mode 100644 index 0000000..642068d --- /dev/null +++ b/convex/semanticSearchQueries.ts @@ -0,0 +1,62 @@ +import { v } from "convex/values"; +import { internalQuery } from "./_generated/server"; + +// Internal query to fetch post details by IDs +export const fetchPostsByIds = internalQuery({ + args: { ids: v.array(v.id("posts")) }, + returns: v.array( + v.object({ + _id: v.id("posts"), + slug: v.string(), + title: v.string(), + description: v.string(), + content: v.string(), + unlisted: v.optional(v.boolean()), + }) + ), + handler: async (ctx, args) => { + const results = []; + for (const id of args.ids) { + const doc = await ctx.db.get(id); + if (doc && doc.published && !doc.unlisted) { + results.push({ + _id: doc._id, + slug: doc.slug, + title: doc.title, + description: doc.description, + content: doc.content, + unlisted: doc.unlisted, + }); + } + } + return results; + }, +}); + +// Internal query to fetch page details by IDs +export const fetchPagesByIds = internalQuery({ + args: { ids: v.array(v.id("pages")) }, + returns: v.array( + v.object({ + _id: v.id("pages"), + slug: v.string(), + title: v.string(), + content: v.string(), + }) + ), + handler: async (ctx, args) => { + const results = []; + for (const id of args.ids) { + const doc = await ctx.db.get(id); + if (doc && doc.published) { + results.push({ + _id: doc._id, + slug: doc.slug, + title: doc.title, + content: doc.content, + }); + } + } + return results; + }, +}); diff --git a/files.md b/files.md index 90157bb..10071f6 100644 --- a/files.md +++ b/files.md @@ -48,7 +48,7 @@ A brief description of each file in the codebase. | `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence | | `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. | | `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. | -| `Dashboard.tsx` | Centralized dashboard at `/dashboard` for content management and site configuration. Features include: Posts and Pages list views with filtering, search, pagination, items per page selector; Post/Page editor with markdown editor, live preview, draggable/resizable frontmatter sidebar (200px-600px), independent scrolling, download markdown; Write Post/Page sections with full-screen writing interface; AI Agent section with tab-based UI for Chat and Image Generation, multi-model selector (Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash), image generation with Nano Banana models and aspect ratio selection; Newsletter management (all Newsletter Admin features integrated); Content import (Firecrawl UI); Site configuration (Config Generator UI for all siteConfig.ts settings); Index HTML editor; Analytics (real-time stats dashboard); Sync commands UI with buttons for all sync operations; Header sync buttons for quick sync; Dashboard search; Toast notifications; Command modal; Mobile responsive design. Uses Convex queries for real-time data, localStorage for preferences, ReactMarkdown for preview. Optional WorkOS authentication via siteConfig.dashboard.requireAuth. When requireAuth is false, dashboard is open access. When requireAuth is true and WorkOS is configured, dashboard requires login. Shows setup instructions if requireAuth is true but WorkOS is not configured. | +| `Dashboard.tsx` | Centralized dashboard at `/dashboard` for content management and site configuration. **Cloud CMS Features:** Direct database save ("Save to DB" button), source tracking (Dashboard vs Synced badges), delete confirmation modal with warning, CRUD operations for dashboard-created content. **Content Management:** Posts and Pages list views with filtering, search, pagination, items per page selector, source badges, delete buttons (dashboard content only); Post/Page editor with markdown editor, live preview, "Save Changes" button, draggable/resizable frontmatter sidebar (200px-600px), independent scrolling, download markdown, export to markdown; Write Post/Page sections with three editor modes (Markdown, Rich Text via Quill, Preview), full-screen writing interface. **Rich Text Editor:** Quill-based WYSIWYG editor with toolbar (headers, bold, italic, lists, links, code, blockquote), automatic HTML-to-Markdown conversion on mode switch, theme-aware styling. **AI Agent:** Tab-based UI for Chat and Image Generation, multi-model selector (Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash), image generation with Nano Banana models and aspect ratio selection. **Other Features:** Newsletter management (all Newsletter Admin features integrated); Content import (direct database import via Firecrawl, no file sync needed); Site configuration (Config Generator UI); Index HTML editor; Analytics (real-time stats dashboard); Sync commands UI with sync server integration; Header sync buttons; Dashboard search; Toast notifications; Command modal; Mobile responsive design. Uses Convex queries for real-time data, localStorage for preferences, ReactMarkdown for preview. Optional WorkOS authentication via siteConfig.dashboard.requireAuth. | | `Callback.tsx` | OAuth callback handler for WorkOS authentication. Handles redirect from WorkOS after user login, exchanges authorization code for user information, then redirects to dashboard. Only used when WorkOS is configured. | | `NewsletterAdmin.tsx` | Three-column newsletter admin page for managing subscribers and sending newsletters. Left sidebar with navigation and stats, main area with searchable subscriber list, right sidebar with send newsletter panel and recent sends. Access at /newsletter-admin, configurable via siteConfig.newsletterAdmin. | @@ -109,10 +109,16 @@ A brief description of each file in the codebase. | File | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, newsletterSubscribers, newsletterSentPosts, contactMessages) with indexes for tag queries (by_tags), AI queries, and blog featured posts (by_blogFeatured). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, and contactForm fields for frontmatter control. | +| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, newsletterSubscribers, newsletterSentPosts, contactMessages) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), and vector search (by_embedding). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. | +| `cms.ts` | CRUD mutations for dashboard cloud CMS: createPost, updatePost, deletePost, createPage, updatePage, deletePage, exportPostAsMarkdown, exportPageAsMarkdown. Posts/pages created via dashboard have `source: "dashboard"` (protected from sync overwrites). | +| `importAction.ts` | Server-side Convex action for direct URL import via Firecrawl API. Scrapes URL, converts to markdown, saves directly to database with `source: "dashboard"`. Requires FIRECRAWL_API_KEY environment variable. | | `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, getRelatedPosts, and getBlogFeaturedPosts. Includes tag-based queries for tag pages and related posts functionality. | | `pages.ts` | Queries and mutations for static pages | | `search.ts` | Full text search queries across posts and pages | +| `semanticSearch.ts` | Vector-based semantic search action using OpenAI embeddings | +| `semanticSearchQueries.ts` | Internal queries for fetching post/page details by IDs for semantic search | +| `embeddings.ts` | Embedding generation actions using OpenAI text-embedding-ada-002 | +| `embeddingsQueries.ts` | Internal queries and mutations for embedding storage and retrieval | | `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat | | `crons.ts` | Cron jobs for stale session cleanup (every 5 minutes), weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC). Uses environment variables SITE_URL and SITE_NAME for email content. | | `http.ts` | HTTP endpoints: sitemap (includes tag pages), API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast), Open Graph HTML generation for social crawlers | @@ -230,6 +236,7 @@ Markdown files for static pages like About, Projects, Contact, Changelog. | `send-newsletter.ts` | CLI tool for sending newsletter posts (npm run newsletter:send ). Calls scheduleSendPostNewsletter mutation directly. | | `send-newsletter-stats.ts` | CLI tool for sending weekly stats summary (npm run newsletter:send:stats). Calls scheduleSendStatsSummary mutation directly. | | `sync-server.ts` | Local HTTP server for executing sync commands from Dashboard UI. Runs on localhost:3001 with optional token authentication. Whitelisted commands only. Part of markdown sync v2. | +| `export-db-posts.ts` | Exports dashboard-created posts and pages to markdown files in `content/blog/` and `content/pages/`. Only exports content with `source: "dashboard"`. Supports development and production environments via `npm run export:db` and `npm run export:db:prod`. | ### Sync Commands @@ -248,6 +255,11 @@ Markdown files for static pages like About, Projects, Contact, Changelog. - `npm run sync:all` - Run both content sync and discovery sync (development) - `npm run sync:all:prod` - Run both content sync and discovery sync (production) +**Export dashboard content:** + +- `npm run export:db` - Export dashboard posts/pages to content folders (development) +- `npm run export:db:prod` - Export dashboard posts/pages (production) + ### Frontmatter Flow Frontmatter is the YAML metadata at the top of each markdown file. Here is how it flows through the system: diff --git a/package-lock.json b/package-lock.json index 3e20ffc..c916741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,22 +23,27 @@ "date-fns": "^3.3.1", "gray-matter": "^4.0.3", "lucide-react": "^0.344.0", - "openai": "^4.104.0", + "openai": "^4.79.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", + "react-quill": "^2.0.0", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^15.5.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "showdown": "^2.1.0", + "turndown": "^7.2.2" }, "devDependencies": { "@types/node": "^25.0.2", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", "@types/react-syntax-highlighter": "^15.5.11", + "@types/showdown": "^2.0.6", + "@types/turndown": "^5.0.6", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", "@vitejs/plugin-react": "^4.2.1", @@ -1190,6 +1195,12 @@ "zod-to-json-schema": "^3.23.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", @@ -3564,6 +3575,15 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/quill": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz", + "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==", + "license": "MIT", + "dependencies": { + "parchment": "^1.1.2" + } + }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", @@ -3594,6 +3614,20 @@ "@types/react": "*" } }, + "node_modules/@types/showdown": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/showdown/-/showdown-2.0.6.tgz", + "integrity": "sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4407,6 +4441,24 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4581,6 +4633,15 @@ "license": "MIT", "peer": true }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4630,6 +4691,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4808,6 +4878,26 @@ "node": ">=6" } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4815,6 +4905,40 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -5381,6 +5505,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -5518,6 +5648,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "license": "Apache-2.0" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5858,6 +5994,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", @@ -6192,6 +6337,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6658,6 +6815,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -6757,6 +6946,24 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6974,6 +7181,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8125,6 +8338,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8267,6 +8505,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8593,6 +8837,34 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "license": "BSD-3-Clause", + "dependencies": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + } + }, + "node_modules/quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "license": "MIT", + "dependencies": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -8747,6 +9019,21 @@ "react": ">=18" } }, + "node_modules/react-quill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz", + "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==", + "license": "MIT", + "dependencies": { + "@types/quill": "^1.3.10", + "lodash": "^4.17.4", + "quill": "^1.3.7" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -9004,6 +9291,26 @@ "node": ">=6" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -9455,6 +9762,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9482,6 +9821,22 @@ "node": ">=8" } }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "license": "MIT", + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -10511,6 +10866,15 @@ "@esbuild/win32-x64": "0.27.1" } }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index b487096..ec82225 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "sync:discovery:prod": "SYNC_ENV=production npx tsx scripts/sync-discovery-files.ts", "sync:all": "npm run sync && npm run sync:discovery", "sync:all:prod": "npm run sync:prod && npm run sync:discovery:prod", + "export:db": "npx tsx scripts/export-db-posts.ts", + "export:db:prod": "SYNC_ENV=production npx tsx scripts/export-db-posts.ts", "import": "npx tsx scripts/import-url.ts", "configure": "npx tsx scripts/configure-fork.ts", "newsletter:send": "npx tsx scripts/send-newsletter.ts", @@ -26,9 +28,9 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.71.2", - "@google/genai": "^1.0.1", "@convex-dev/aggregate": "^0.2.0", "@convex-dev/workos": "^0.0.1", + "@google/genai": "^1.0.1", "@mendable/firecrawl-js": "^1.21.1", "@modelcontextprotocol/sdk": "^1.0.0", "@phosphor-icons/react": "^2.1.10", @@ -44,18 +46,23 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", + "react-quill": "^2.0.0", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^15.5.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "showdown": "^2.1.0", + "turndown": "^7.2.2" }, "devDependencies": { "@types/node": "^25.0.2", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", "@types/react-syntax-highlighter": "^15.5.11", + "@types/showdown": "^2.0.6", + "@types/turndown": "^5.0.6", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", "@vitejs/plugin-react": "^4.2.1", diff --git a/public/llms.txt b/public/llms.txt index 091d76e..fa15d15 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,6 +1,6 @@ # llms.txt - Information for AI assistants and LLMs # Learn more: https://llmstxt.org/ -# Last updated: 2026-01-04T17:25:36.682Z +# Last updated: 2026-01-05T18:54:36.241Z > Your content is instantly available to browsers, LLMs, and AI agents. diff --git a/public/raw/about.md b/public/raw/about.md index b372af8..0e77eb9 100644 --- a/public/raw/about.md +++ b/public/raw/about.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify. @@ -82,7 +82,9 @@ It's a hybrid: developer workflow for publishing + real-time delivery like a dyn **Search and discovery:** -- Full text search with Command+K shortcut +- Dual search modes: Keyword (exact match) and Semantic (meaning-based) with Cmd+K toggle +- Semantic search uses OpenAI embeddings for finding conceptually similar content +- Full text search with Command+K shortcut and result highlighting - Static raw markdown files at `/raw/{slug}.md` - RSS feeds (`/rss.xml` and `/rss-full.xml`) and sitemap for SEO - API endpoints for AI/LLM access (`/api/posts`, `/api/export`) diff --git a/public/raw/changelog.md b/public/raw/changelog.md index 1f8be94..906dba0 100644 --- a/public/raw/changelog.md +++ b/public/raw/changelog.md @@ -2,11 +2,141 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- All notable changes to this project. +## v2.10.0 + +Released January 5, 2026 + +**Semantic search with vector embeddings** + +Search now supports two modes accessible via Cmd+K: + +- **Keyword search** (existing) - Matches exact words using Convex full-text search. Instant, free, supports highlighting. +- **Semantic search** (new) - Finds content by meaning using OpenAI embeddings. Toggle to "Semantic" mode in search modal. + +**How semantic search works:** + +1. Your query is converted to a 1536-dimension vector using OpenAI text-embedding-ada-002 +2. Convex compares this vector to stored embeddings for all posts and pages +3. Results ranked by similarity score (displayed as percentage) +4. Top 15 results returned + +**When to use each mode:** + +| Use Case | Mode | +|----------|------| +| Specific code, commands, exact phrases | Keyword | +| Conceptual questions ("how do I deploy?") | Semantic | +| Need to highlight matches on page | Keyword | +| Not sure of exact terminology | Semantic | + +**Configuration:** + +Semantic search requires an OpenAI API key: + +```bash +npx convex env set OPENAI_API_KEY sk-your-key-here +npm run sync # Generates embeddings for all content +``` + +If OPENAI_API_KEY is not configured, semantic search returns empty results and keyword search continues to work normally. + +**Technical details:** + +- New files: `convex/embeddings.ts`, `convex/embeddingsQueries.ts`, `convex/semanticSearch.ts`, `convex/semanticSearchQueries.ts` +- Added `embedding` field and `by_embedding` vector index to posts and pages tables +- SearchModal.tsx updated with Keyword/Semantic toggle (TextAa and Brain icons) +- Embeddings generated automatically during `npm run sync` +- Cost: ~$0.0001 per search query (embedding generation) + +Updated files: `convex/schema.ts`, `convex/embeddings.ts`, `convex/embeddingsQueries.ts`, `convex/semanticSearch.ts`, `convex/semanticSearchQueries.ts`, `src/components/SearchModal.tsx`, `scripts/sync-posts.ts`, `src/styles/global.css`, `content/pages/docs-search.md`, `content/pages/docs-semantic-search.md` + +## v2.9.0 + +Released January 4, 2026 + +**Dashboard Cloud CMS Features** + +The Dashboard now functions as a WordPress-style cloud CMS, allowing content creation and editing directly in the database without requiring the markdown file sync workflow. + +**Dual Source Architecture:** + +- Dashboard-created content marked with `source: "dashboard"` +- Markdown-synced content marked with `source: "sync"` +- Both coexist independently in the database +- Sync operations only affect synced content (dashboard content protected) +- Source badges in Posts and Pages list views (blue "Dashboard", gray "Synced") + +**Direct Database Operations:** + +- "Save to DB" button in Write Post/Page sections saves directly to database +- "Save Changes" button in Post/Page editor updates content immediately +- Delete button for dashboard-created content (synced content protected) +- Changes appear instantly without requiring sync + +**Delete Confirmation Modal:** + +- Warning modal displayed before deleting posts or pages +- Shows item name and type being deleted +- Themed to match dashboard UI with danger button styling +- Backdrop click and Escape key to cancel + +**Rich Text Editor:** + +- Three editing modes: Markdown (default), Rich Text (Quill WYSIWYG), Preview +- Quill-based editor with formatting toolbar +- Toolbar: headers (H1-H3), bold, italic, strikethrough, blockquote, code, lists, links +- Automatic HTML-to-Markdown conversion when switching modes +- Theme-aware styling using CSS variables + +**Server-Side URL Import:** + +- Direct database import via Firecrawl (no file sync needed) +- Enter URL in Import section, content is scraped and saved to database +- Optional "Publish immediately" checkbox +- Imported posts tagged with `imported` by default +- Requires `FIRECRAWL_API_KEY` in Convex environment variables + +**Export to Markdown:** + +- Export any post/page to `.md` file with complete frontmatter +- Bulk export script: `npm run export:db` (dev) or `npm run export:db:prod` (prod) +- Use for backup or converting dashboard content to file-based workflow + +**Technical details:** + +- New file: `convex/cms.ts` with CRUD mutations +- New file: `convex/importAction.ts` with Firecrawl server-side action +- New file: `scripts/export-db-posts.ts` for bulk markdown export +- Added `source` field and `by_source` index to posts and pages tables +- Added ConfirmDeleteModal component to Dashboard.tsx +- Fixed list row grid layout for proper source badge display + +Updated files: `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `convex/cms.ts`, `convex/importAction.ts`, `scripts/export-db-posts.ts`, `src/pages/Dashboard.tsx`, `src/styles/global.css`, `package.json`, `content/pages/docs-dashboard.md` + +## v2.8.7 + +Released January 4, 2026 + +**Write page frontmatter sidebar toggle fix** + +- Frontmatter sidebar toggle now works outside focus mode + - Grid layout adjusts properly when frontmatter sidebar is collapsed + - Previously only worked in focus mode due to missing CSS rules + - Both sidebars can now be collapsed independently or together + +**Technical details:** + +- Added `.write-layout.frontmatter-collapsed` CSS rule (grid-template-columns: 220px 1fr 56px) +- Added `.write-layout.sidebar-collapsed.frontmatter-collapsed` CSS rule for both sidebars collapsed +- Added responsive tablet styles for frontmatter collapsed state + +Updated files: `src/styles/global.css` + ## v2.8.6 Released January 4, 2026 diff --git a/public/raw/contact.md b/public/raw/contact.md index 82a09fe..0b6bc53 100644 --- a/public/raw/contact.md +++ b/public/raw/contact.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- You found the contact page. Nice diff --git a/public/raw/docs-configuration.md b/public/raw/docs-configuration.md new file mode 100644 index 0000000..cc4c23a --- /dev/null +++ b/public/raw/docs-configuration.md @@ -0,0 +1,436 @@ +# Configuration + +--- +Type: page +Date: 2026-01-06 +--- + +## Configuration + +### Fork configuration + +After forking, you have two options to configure your site: + +**Option 1: Automated (Recommended)** + +```bash +cp fork-config.json.example fork-config.json +# Edit fork-config.json with your site information +npm run configure +``` + +This updates all 11 configuration files in one command. See `FORK_CONFIG.md` for the full JSON schema and options. + +**Option 2: Manual** + +Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually. + +### Files updated by configuration + +| File | What to update | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `src/config/siteConfig.ts` | Site name, title, intro, bio, blog page, logo gallery, GitHub contributions, right sidebar configuration | +| `src/pages/Home.tsx` | Intro paragraph text, footer links | +| `convex/http.ts` | `SITE_URL`, `SITE_NAME`, description strings (3 locations) | +| `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, topics | +| `public/robots.txt` | Sitemap URL and header comment | +| `public/openapi.yaml` | API title, server URL, site name in examples | +| `public/.well-known/ai-plugin.json` | Site name, descriptions | +| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) | + +### Site title and description metadata + +These files contain the main site description text. Update them with your own tagline: + +| File | What to change | +| --------------------------------- | -------------------------------------------------------------- | +| `index.html` | meta description, og:description, twitter:description, JSON-LD | +| `README.md` | Main description at top of file | +| `src/config/siteConfig.ts` | name, title, and bio fields | +| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) | +| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) | +| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants | +| `public/llms.txt` | Header quote, Name, and Description fields | +| `public/openapi.yaml` | API title and example site name | +| `AGENTS.md` | Project overview section | +| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph | +| `content/pages/about.md` | excerpt field and opening paragraph | +| `content/pages/docs.md` | Opening description paragraph | + +**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. + +### Site settings + +Edit `src/config/siteConfig.ts`: + +```typescript +export default { + name: "Site Name", + title: "Tagline", + logo: "/images/logo.svg", // null to hide homepage logo + intro: "Introduction text...", + bio: "Bio text...", + + // Blog page configuration + blogPage: { + enabled: true, // Enable /blog route + showInNav: true, // Show in navigation + title: "Blog", // Nav link and page title + order: 0, // Nav order (lower = first) + }, + + // Hardcoded navigation items for React routes + hardcodedNavItems: [ + { + slug: "stats", + title: "Stats", + order: 10, + showInNav: true, // Set to false to hide from nav + }, + { + slug: "write", + title: "Write", + order: 20, + showInNav: true, + }, + ], + + // Inner page logo configuration + innerPageLogo: { + enabled: true, // Set to false to hide logo on inner pages + size: 28, // Logo height in pixels (keeps aspect ratio) + }, + + // Featured section + featuredViewMode: "list", // 'list' or 'cards' + showViewToggle: true, + + // Logo gallery (static grid or scrolling marquee) + logoGallery: { + enabled: true, // false to hide + images: [{ src: "/images/logos/logo.svg", href: "https://example.com" }], + position: "above-footer", + speed: 30, + title: "Built with", + scrolling: false, // false = static grid, true = scrolling marquee + maxItems: 4, // Number of logos when scrolling is false + }, + + links: { + docs: "/docs", + convex: "https://convex.dev", + }, +}; +``` + +**Logo configuration:** + +- `logo`: Homepage logo path (set to `null` to hide). Uses `public/images/logo.svg` by default. +- `innerPageLogo`: Logo shown on blog page, posts, and static pages. Desktop: top left. Mobile: top right. Set `enabled: false` to hide on inner pages while keeping homepage logo. + +**Navigation structure:** + +Navigation combines three sources sorted by `order`: + +1. Blog link (if `blogPage.enabled` and `blogPage.showInNav` are true) +2. Hardcoded nav items (React routes from `hardcodedNavItems`) +3. Markdown pages (from `content/pages/` with `showInNav: true`) + +All items sort by `order` (lower first), then alphabetically by title. + +### 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` or `npm run sync:all`. 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 +}; +``` + +### GitHub contributions graph + +Display your GitHub contribution activity on the homepage. Configure in `siteConfig`: + +```typescript +gitHubContributions: { + enabled: true, // Set to false to hide + username: "yourusername", // Your GitHub username + showYearNavigation: true, // Show arrows to navigate between years + linkToProfile: true, // Click graph to open GitHub profile + title: "GitHub Activity", // Optional title above the graph +}, +``` + +| Option | Description | +| -------------------- | -------------------------------------- | +| `enabled` | `true` to show, `false` to hide | +| `username` | Your GitHub username | +| `showYearNavigation` | Show prev/next year navigation | +| `linkToProfile` | Click graph to visit GitHub profile | +| `title` | Text above graph (`undefined` to hide) | + +Theme-aware colors match each site theme. Uses public API (no GitHub token required). + +### Visitor map + +Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. + +```typescript +visitorMap: { + enabled: true, // Set to false to hide + title: "Live Visitors", // Optional title above the map +}, +``` + +| Option | Description | +| --------- | ------------------------------------ | +| `enabled` | `true` to show, `false` to hide | +| `title` | Text above map (`undefined` to hide) | + +The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. + +### Logo gallery + +The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. + +```typescript +// In src/config/siteConfig.ts +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: "Built with", // undefined to hide + scrolling: false, // false = static grid, true = scrolling marquee + maxItems: 4, // Number of logos when scrolling is false +}, +``` + +| 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) | +| `scrolling` | `true` for infinite scroll, `false` for static grid | +| `maxItems` | Max logos to show when `scrolling` is `false` (default: 4) | + +**Display modes:** + +- `scrolling: true`: Infinite horizontal scroll with all logos +- `scrolling: false`: Static centered grid showing first `maxItems` logos + +**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. + +### Blog page + +The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`: + +```typescript +blogPage: { + enabled: true, // Enable /blog route + showInNav: true, // Show in navigation + title: "Blog", // Nav link and page title + order: 0, // Nav order (lower = first) + viewMode: "list", // Default view: "list" or "cards" + showViewToggle: true, // Show toggle button to switch views +}, +displayOnHomepage: true, // Show posts on homepage +``` + +| Option | Description | +| ------------------- | -------------------------------------- | +| `enabled` | Enable the `/blog` route | +| `showInNav` | Show Blog link in navigation | +| `title` | Text for nav link and page heading | +| `order` | Position in navigation (lower = first) | +| `viewMode` | Default view: `"list"` or `"cards"` | +| `showViewToggle` | Show toggle button to switch views | +| `displayOnHomepage` | Show post list on homepage | + +**View modes:** + +- **List view:** Year-grouped posts with titles, read time, and dates +- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata + +**Card view details:** + +Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile. + +**Display options:** + +- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false` +- Blog page only: `displayOnHomepage: false`, `blogPage.enabled: true` +- Both: `displayOnHomepage: true`, `blogPage.enabled: true` + +**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4. + +**View preference:** User's view mode choice is saved to localStorage and persists across page visits. + +### Scroll-to-top button + +A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`: + +```typescript +const scrollToTopConfig: Partial = { + 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`. + +Configure in `src/config/siteConfig.ts`: + +```typescript +export const siteConfig: SiteConfig = { + // ... other config + defaultTheme: "tan", +}; +``` + +### Font + +Configure the font in `src/config/siteConfig.ts`: + +```typescript +export const siteConfig: SiteConfig = { + // ... other config + fontFamily: "serif", // Options: "serif", "sans", or "monospace" +}; +``` + +Or edit `src/styles/global.css` directly: + +```css +body { + /* Sans-serif */ + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Serif (default) */ + font-family: "New York", ui-serif, Georgia, serif; + + /* Monospace */ + font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace; +} +``` + +Available options: `serif` (default), `sans`, or `monospace`. + +### Font sizes + +All font sizes use CSS variables in `:root`. Customize by editing: + +```css +:root { + --font-size-base: 16px; + --font-size-sm: 13px; + --font-size-lg: 17px; + --font-size-blog-content: 17px; + --font-size-post-title: 32px; +} +``` + +Mobile sizes defined in `@media (max-width: 768px)` block. + +### 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 | + +**Images require git deploy.** Images are served as static files from your repository, not synced to Convex. After adding images to `public/images/`: + +1. Commit the image files to git +2. Push to GitHub +3. Wait for Netlify to rebuild + +The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes. + +**Adding images to posts:** You can add images using markdown syntax `![alt](src)` or HTML `` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices. + +**Logo options:** + +- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide. +- **Inner page logo:** Configured via `innerPageLogo` in `siteConfig.ts`. Shows on blog page, posts, and static pages. Desktop: top left corner. Mobile: top right corner (smaller). Set `enabled: false` to hide on inner pages while keeping homepage logo. \ No newline at end of file diff --git a/public/raw/docs-content.md b/public/raw/docs-content.md new file mode 100644 index 0000000..ed94144 --- /dev/null +++ b/public/raw/docs-content.md @@ -0,0 +1,299 @@ +# Content + +--- +Type: page +Date: 2026-01-06 +--- + +## Content + +**Markdown examples:** For complete markdown syntax examples including code blocks, tables, lists, links, images, collapsible sections, and all formatting options, see [Writing Markdown with Code Examples](/markdown-with-code-examples). That post includes copy-paste examples for every markdown feature. + +### 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... +``` + +See the [Frontmatter](/docs-frontmatter) page for all available fields. + +### Static pages + +Create files in `content/pages/` with frontmatter: + +```markdown +--- +title: "Page Title" +slug: "url-path" +published: true +order: 1 +--- + +Content here... +``` + +See the [Frontmatter](/docs-frontmatter) page for all available fields. + +### Home intro content + +The homepage intro text can be synced from markdown via `content/pages/home.md` (slug: `home-intro`). This allows you to update homepage text without redeploying. + +**Create home intro:** + +1. Create `content/pages/home.md`: + +```markdown +--- +title: "Home Intro" +slug: "home-intro" +published: true +showInNav: false +order: -1 +textAlign: "left" +--- + +Your homepage intro text here. + +## Features + +**Feature one** - Description here. + +**Feature two** - Description here. +``` + +2. Run `npm run sync` to sync to Convex + +3. Content appears on homepage instantly (no rebuild needed) + +**Blog heading styles:** Headings (h1-h6) in home intro content use the same styling as blog posts (`blog-h1` through `blog-h6` classes). Each heading gets an automatic ID and a clickable anchor link (#) that appears on hover. Lists, blockquotes, horizontal rules, and links also use blog styling classes for consistent typography. + +**Fallback:** If `home-intro` page is not found, the homepage falls back to `siteConfig.bio` text. + +### Footer content + +The footer content can be synced from markdown via `content/pages/footer.md` (slug: `footer`). This allows you to update footer text without touching code. + +**Create footer content:** + +1. Create `content/pages/footer.md`: + +```markdown +--- +title: "Footer" +slug: "footer" +published: true +showInNav: false +order: -1 +--- + +Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). + +Created by [Your Name](https://x.com/yourhandle). Follow on [Twitter/X](https://x.com/yourhandle) and [GitHub](https://github.com/yourusername). +``` + +2. Run `npm run sync` to sync to Convex + +3. Footer content appears on homepage, blog page, and all posts/pages instantly (no rebuild needed) + +**Markdown support:** Footer content supports full markdown including links, paragraphs, line breaks, and images. External links automatically open in new tabs. + +**Fallback:** If `footer` page is not found, the footer falls back to `siteConfig.footer.defaultContent`. + +**Priority order:** Per-post/page frontmatter `footer:` field (custom override) > synced footer.md content > siteConfig.footer.defaultContent. + +**Relationship with siteConfig:** The `content/pages/footer.md` page takes priority over `siteConfig.footer.defaultContent` when present. Use the markdown page for dynamic content that changes frequently, or keep using siteConfig for static footer content. + +### Sidebar layout + +Posts and pages can use a docs-style layout with a table of contents sidebar. Add `layout: "sidebar"` to the frontmatter: + +```markdown +--- +title: "Documentation" +slug: "docs" +published: true +layout: "sidebar" +--- + +# Introduction + +## Section One + +### Subsection + +## Section Two +``` + +**Features:** + +- Left sidebar displays table of contents extracted from H1, H2, H3 headings +- Two-column layout: 220px sidebar + flexible content area +- Sidebar only appears if headings exist in the content +- Active heading highlighting as you scroll +- Smooth scroll navigation when clicking TOC links +- Mobile responsive: stacks to single column below 1024px +- Works for both blog posts and static pages + +The sidebar extracts headings automatically from your markdown content. No manual TOC needed. + +### Right sidebar + +When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. + +**Configuration:** + +Enable globally in `src/config/siteConfig.ts`: + +```typescript +rightSidebar: { + enabled: true, // Set to false to disable right sidebar globally + minWidth: 1135, // Minimum viewport width to show sidebar +}, +``` + +Control per post/page with frontmatter: + +```markdown +--- +title: "My Post" +rightSidebar: true # Enable right sidebar for this post +--- +``` + +**Features:** + +- Right sidebar appears at 1135px+ viewport width +- Contains CopyPageDropdown with all sharing options +- Three-column layout: left sidebar (TOC), main content, right sidebar +- CopyPageDropdown automatically moves from nav to right sidebar when enabled +- Hidden below 1135px breakpoint, CopyPageDropdown returns to nav +- Per-post/page control via `rightSidebar: true` frontmatter field +- Opt-in only: right sidebar only appears when explicitly enabled in frontmatter + +**Use cases:** + +- Keep CopyPageDropdown accessible on wide screens without cluttering the nav +- Provide quick access to sharing options while reading long content +- Works alongside left sidebar TOC for comprehensive navigation + +**Example for blog post:** + +```markdown +--- +title: "My Tutorial" +description: "A detailed guide" +date: "2025-01-20" +slug: "my-tutorial" +published: true +tags: ["tutorial"] +layout: "sidebar" +--- + +# Introduction + +## Getting Started + +### Prerequisites + +## Advanced Topics +``` + +### How frontmatter works + +Frontmatter is the YAML metadata at the top of each markdown file between `---` markers. Here is how it flows through the system: + +**Content directories:** + +- `content/blog/*.md` contains blog posts with frontmatter +- `content/pages/*.md` contains static pages with frontmatter + +**Processing flow:** + +1. Markdown files in `content/blog/` and `content/pages/` contain YAML frontmatter +2. `scripts/sync-posts.ts` uses `gray-matter` to parse frontmatter and validate required fields +3. Parsed data is sent to Convex mutations (`api.posts.syncPostsPublic`, `api.pages.syncPagesPublic`) +4. `convex/schema.ts` defines the database structure for storing the data + +**Adding a new frontmatter field:** + +To add a custom frontmatter field, update these files: + +1. The interface in `scripts/sync-posts.ts` (`PostFrontmatter` or `PageFrontmatter`) +2. The parsing logic in `parseMarkdownFile()` or `parsePageFile()` functions +3. The schema in `convex/schema.ts` +4. The sync mutation in `convex/posts.ts` or `convex/pages.ts` + +### Syncing content + +**Development:** + +```bash +npm run sync # Sync markdown content +npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt) +npm run sync:all # Sync content + discovery files together +``` + +**Production:** + +```bash +npm run sync:prod # Sync markdown content +npm run sync:discovery:prod # Update discovery files +npm run sync:all:prod # Sync content + discovery files together +``` + +**Sync everything together:** + +```bash +npm run sync:all # Development: content + discovery +npm run sync:all:prod # Production: content + discovery +``` + +### 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) | +| Site config changes | `npm run sync:discovery` | Updates discovery files | +| Import external URL | `npm run import` then sync | Instant (no rebuild) | +| Images in `public/images/` | Git commit + push | Requires rebuild | +| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | +| Logo gallery config | Redeploy | Requires rebuild | +| React components/styles | Redeploy | Requires rebuild | + +**Markdown content** syncs instantly to Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild. + +## Tag pages and related posts + +Tag pages are available at `/tags/[tag]` for each tag used in your posts. They display all posts with that tag in a list or card view with localStorage persistence for view mode preference. + +**Related posts:** Individual blog posts show up to 3 related posts in the footer based on shared tags. Posts are sorted by relevance (number of shared tags) then by date. Only appears on blog posts (not static pages). + +**Tag links:** Tags in post footers link to their respective tag archive pages. + +## Blog page featured layout + +Posts can be marked as featured on the blog page using the `blogFeatured` frontmatter field: + +```yaml +--- +title: "My Featured Post" +blogFeatured: true +--- +``` + +The first `blogFeatured` post displays as a hero card with landscape image, tags, date, title, excerpt, author info, and read more link. Remaining `blogFeatured` posts display in a 2-column featured row with excerpts. Regular (non-featured) posts display in a 3-column grid without excerpts. \ No newline at end of file diff --git a/public/raw/docs-dashboard.md b/public/raw/docs-dashboard.md new file mode 100644 index 0000000..992cfbe --- /dev/null +++ b/public/raw/docs-dashboard.md @@ -0,0 +1,392 @@ +# Dashboard + +--- +Type: page +Date: 2026-01-06 +--- + +## Dashboard + +The Dashboard at `/dashboard` provides a centralized UI for managing content, configuring the site, and performing sync operations. It's designed for developers who fork the repository to set up and manage their markdown blog. + +**Access:** Navigate to `/dashboard` in your browser. The dashboard is not linked in the navigation by default (similar to Newsletter Admin pattern). + +**Authentication:** WorkOS authentication is optional. Configure it in `siteConfig.ts`: + +```typescript +dashboard: { + enabled: true, + requireAuth: false, // Set to true to require WorkOS authentication +}, +``` + +When `requireAuth` is `false`, the dashboard is open access. When `requireAuth` is `true` and WorkOS is configured, users must log in to access the dashboard. See [How to setup WorkOS](https://www.markdown.fast/how-to-setup-workos) for authentication setup. + +### Content management + +**Posts and Pages List Views:** + +- View all posts and pages (published and unpublished) +- Filter by status: All, Published, Drafts +- Search by title or content +- Pagination with "First" and "Next" buttons +- Items per page selector (15, 25, 50, 100) - default: 15 +- Edit, view, and publish/unpublish options +- WordPress-style UI with date, edit, view, and publish controls +- **Source Badge:** Shows "Dashboard" or "Synced" to indicate content origin +- **Delete Button:** Delete dashboard-created posts/pages directly (synced content protected) + +**Post and Page Editor:** + +- Markdown editor with live preview +- Frontmatter sidebar on the right with all available fields +- Draggable/resizable frontmatter sidebar (200px-600px width) +- Independent scrolling for frontmatter sidebar +- Preview mode shows content as it appears on the live site +- Download markdown button to generate `.md` files +- Copy markdown to clipboard +- All frontmatter fields editable in sidebar +- Preview uses ReactMarkdown with proper styling +- **Save to Database:** Green "Save" button saves changes directly to the database + +**Write Post and Write Page:** + +- Full-screen writing interface +- **Three Editor Modes:** + - **Markdown:** Raw markdown editing (default) + - **Rich Text:** WYSIWYG Quill editor with toolbar + - **Preview:** Rendered markdown preview +- Word/line/character counts +- Frontmatter reference panel +- Download markdown button for new content +- **Save to DB:** Save directly to database without file sync +- Content persists in localStorage +- Separate storage for post and page content + +### Cloud CMS features + +The dashboard functions as a cloud-based CMS similar to WordPress, allowing you to create and edit content directly in the database without requiring the markdown file sync workflow. + +**Dual Source Architecture:** + +- **Dashboard Content:** Posts/pages created via "Save to DB" are marked with `source: "dashboard"` +- **Synced Content:** Posts/pages from markdown files are marked with `source: "sync"` +- Both coexist independently in the database +- Sync operations only affect synced content (dashboard content is protected) + +**Direct Database Operations:** + +- Create new posts/pages directly in the database +- Edit any post/page and save changes immediately +- Delete dashboard-created content +- Changes appear instantly (no sync required) + +**Export to Markdown:** + +- Any post/page can be exported as a `.md` file +- Includes all frontmatter fields +- Use for backup or converting to file-based workflow + +**Bulk Export Script:** + +```bash +npm run export:db # Export dashboard posts to content/blog/ +npm run export:db:prod # Export from production database +``` + +Exports all dashboard-created posts and pages to markdown files in the content folders. + +### Rich Text Editor + +The Write Post and Write Page sections include a Quill-based rich text editor with three editing modes. + +**Editing Modes:** + +- **Markdown:** Raw markdown text editing (default mode) +- **Rich Text:** WYSIWYG editor with formatting toolbar +- **Preview:** Rendered preview of the content + +**Rich Text Toolbar:** + +- Headers (H1, H2, H3) +- Bold, italic, strikethrough +- Blockquote, code block +- Ordered and bullet lists +- Links +- Clear formatting + +**Mode Switching:** + +- Content automatically converts between HTML and Markdown when switching modes +- Frontmatter is preserved when editing in Rich Text mode +- Preview mode shows how content will appear on the live site + +**Theme Integration:** + +- Editor styling matches the current theme (dark, light, tan, cloud) +- Toolbar uses CSS variables for consistent appearance + +### AI Agent + +The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat and Image Generation. + +**Chat Tab:** + +- Multi-model selector: Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash +- Per-session chat history stored in Convex +- Markdown rendering for AI responses +- Copy functionality for AI responses +- Lazy API key validation (errors only shown when user tries to use a specific model) + +**Image Tab:** + +- AI image generation with two models: + - Nano Banana (gemini-2.0-flash-exp-image-generation) - Experimental model + - Nano Banana Pro (imagen-3.0-generate-002) - Production model +- Aspect ratio selection: 1:1, 16:9, 9:16, 4:3, 3:4 +- Images stored in Convex storage with session tracking +- Gallery view of recent generated images + +**Environment Variables (Convex):** + +| Variable | Description | +| ------------------- | -------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 | +| `OPENAI_API_KEY` | Required for GPT-4o | +| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | + +**Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model. + +### Newsletter management + +All Newsletter Admin features integrated into the Dashboard: + +- **Subscribers:** View, search, filter, and delete subscribers +- **Send Newsletter:** Select a blog post to send as newsletter +- **Write Email:** Compose custom emails with markdown support +- **Recent Sends:** View last 10 newsletter sends (posts and custom emails) +- **Email Stats:** Dashboard with total emails, newsletters sent, active subscribers, retention rate + +All newsletter sections are full-width in the dashboard content area. + +### Content import + +**Direct Database Import:** + +The Import URL section uses server-side Firecrawl to import articles directly to the database. + +- Enter any article URL to import +- Firecrawl scrapes and converts content to markdown +- Post is saved directly to the database (no file sync needed) +- Optional "Publish immediately" checkbox +- Imported posts tagged with `imported` by default +- Source attribution added automatically +- Success message with link to view the imported post + +**Setup:** + +Add `FIRECRAWL_API_KEY` to your Convex environment variables: + +```bash +npx convex env set FIRECRAWL_API_KEY your-api-key-here +``` + +Get your API key from [firecrawl.dev](https://firecrawl.dev). + +**CLI Import (Alternative):** + +You can also import via command line: + +```bash +npm run import # Import URL as local markdown file +``` + +This creates a file in `content/blog/` that requires syncing. + +### Site configuration + +**Config Generator:** + +- UI to configure all settings in `src/config/siteConfig.ts` +- Generates downloadable `siteConfig.ts` file +- Hybrid approach: dashboard generates config, file-based config continues to work +- Includes all site configuration options: + - Site name, title, logo, bio, intro + - Blog page settings + - Featured section configuration + - Logo gallery settings + - GitHub contributions + - Footer and social footer + - Newsletter settings + - Contact form settings + - Stats page settings + - And more + +**Index HTML Editor:** + +- View and edit `index.html` content +- Meta tags, Open Graph, Twitter Cards, JSON-LD +- Download updated HTML file + +### Analytics + +- Real-time stats dashboard (clone of `/stats` page) +- Active visitors with per-page breakdown +- Total page views and unique visitors +- Views by page sorted by popularity +- Does not follow `siteConfig.statsPage` settings (always accessible in dashboard) + +### Sync commands + +**Sync Content Section:** + +- UI with buttons for all sync operations +- Development sync commands: + - `npm run sync` - Sync markdown content + - `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) + - `npm run sync:all` - Sync content + discovery files together +- Production sync commands: + - `npm run sync:prod` - Sync markdown content + - `npm run sync:discovery:prod` - Update discovery files + - `npm run sync:all:prod` - Sync content + discovery files together +- Server status indicator shows if sync server is online +- Copy and Execute buttons for each command +- Real-time terminal output when sync server is running +- Command modal shows full command output when sync server is offline +- Toast notifications for success/error feedback + +**Sync Server:** + +- Local HTTP server for executing commands from dashboard +- Start with `npm run sync-server` (runs on localhost:3001) +- Execute commands directly from dashboard with real-time output streaming +- Optional token authentication via `SYNC_TOKEN` environment variable +- Whitelisted commands only for security +- Health check endpoint for server availability detection +- Copy icons for `npm run sync-server` command in dashboard + +**Header Sync Buttons:** + +- Quick sync buttons in dashboard header (right side) +- `npm run sync:all` (dev) button +- `npm run sync:all:prod` (prod) button +- One-click sync for all content and discovery files +- Automatically use sync server when available, fallback to command modal + +### Dashboard features + +**Search:** + +- Search bar in header +- Search dashboard features, page titles, and post content +- Real-time results as you type + +**Theme and Font:** + +- Theme toggle (dark, light, tan, cloud) +- Font switcher (serif, sans, monospace) +- Preferences persist across sessions + +**Mobile Responsive:** + +- Fully responsive design +- Mobile-optimized layout +- Touch-friendly controls +- Collapsible sidebar on mobile + +**Toast Notifications:** + +- Success, error, info, and warning notifications +- Auto-dismiss after 4 seconds +- Theme-aware styling +- No browser default alerts + +**Command Modal:** + +- Shows sync command output +- Copy command to clipboard +- Close button to dismiss +- Theme-aware styling + +### Technical details + +**Database Architecture:** + +- Uses Convex queries for real-time data +- All mutations follow Convex best practices (idempotent, indexed queries) +- `source` field tracks content origin ("dashboard" or "sync") +- `by_source` index for efficient filtering by source + +**CMS Mutations (convex/cms.ts):** + +- `createPost` / `createPage` - Create with `source: "dashboard"` +- `updatePost` / `updatePage` - Update any post/page +- `deletePost` / `deletePage` - Delete any post/page +- `exportPostAsMarkdown` / `exportPageAsMarkdown` - Generate markdown with frontmatter + +**Import Action (convex/importAction.ts):** + +- Server-side Convex action using Firecrawl +- Scrapes URL, converts to markdown, saves to database +- Handles slug conflicts by appending timestamp + +**UI State:** + +- Frontmatter sidebar width persisted in localStorage +- Editor content persisted in localStorage +- Independent scrolling for editor and sidebar sections +- Preview uses ReactMarkdown with remark-gfm, remark-breaks, rehype-raw, rehype-sanitize +- Rich text editor uses Quill with Turndown/Showdown for conversion + +### Sync commands reference + +**Development:** + +- `npm run sync` - Sync markdown content to development Convex +- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data +- `npm run sync:all` - Run both content sync and discovery sync (development) + +**Production:** + +- `npm run sync:prod` - Sync markdown content to production Convex +- `npm run sync:discovery:prod` - Update discovery files with production data +- `npm run sync:all:prod` - Run both content sync and discovery sync (production) + +**Sync Server:** + +- `npm run sync-server` - Start local HTTP server for executing sync commands from dashboard UI + +**Content Import:** + +- `npm run import ` - Import external URL as markdown post (requires FIRECRAWL_API_KEY in .env.local) + +**Database Export:** + +- `npm run export:db` - Export dashboard posts/pages to content folders (development) +- `npm run export:db:prod` - Export dashboard posts/pages (production) + +**Note:** The dashboard provides a UI for these commands. When the sync server is running (`npm run sync-server`), you can execute commands directly from the dashboard with real-time output. Otherwise, the dashboard shows commands in a modal for copying to your terminal. + +### Environment variables + +**Convex Environment Variables:** + +| Variable | Description | +| ------------------- | -------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 (AI Agent) | +| `OPENAI_API_KEY` | Required for GPT-4o (AI Agent) | +| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | +| `FIRECRAWL_API_KEY` | Required for direct URL import | + +Set Convex environment variables with: + +```bash +npx convex env set VARIABLE_NAME value +``` + +**Local Environment Variables (.env.local):** + +| Variable | Description | +| ------------------- | ------------------------------------------ | +| `VITE_CONVEX_URL` | Your Convex deployment URL (auto-created) | +| `FIRECRAWL_API_KEY` | For CLI import command only | \ No newline at end of file diff --git a/public/raw/docs-deployment.md b/public/raw/docs-deployment.md new file mode 100644 index 0000000..aa557dd --- /dev/null +++ b/public/raw/docs-deployment.md @@ -0,0 +1,98 @@ +# Deployment + +--- +Type: page +Date: 2026-01-06 +--- + +## 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) + authorName: v.optional(v.string()), // Author display name + authorImage: v.optional(v.string()), // Author avatar image URL + 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) + authorName: v.optional(v.string()), // Author display name + authorImage: v.optional(v.string()), // Author avatar image URL + 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 +- Use `npm run sync:all` or `npm run sync:all:prod` to sync content and update discovery files together +- 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+) \ No newline at end of file diff --git a/public/raw/docs-frontmatter.md b/public/raw/docs-frontmatter.md new file mode 100644 index 0000000..edce2cd --- /dev/null +++ b/public/raw/docs-frontmatter.md @@ -0,0 +1,113 @@ +# Frontmatter + +--- +Type: page +Date: 2026-01-06 +--- + +## Frontmatter + +Frontmatter is the YAML metadata at the top of each markdown file between `---` markers. It controls how content is displayed, organized, and discovered. + +## Blog post fields + +| 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. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | +| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | +| `excerpt` | No | Short text for card view | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this post (overrides siteConfig default) | +| `footer` | No | Per-post footer markdown (overrides `footer.md` and siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this post | +| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | +| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | +| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | +| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | +| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | +| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | +| `docsLanding` | No | Set `true` to use this post as the docs landing page (shown when navigating to `/docs`). | + +## Page fields + +| Field | Required | Description | +| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | Yes | Nav link text | +| `slug` | Yes | URL path | +| `published` | Yes | `true` to show | +| `order` | No | Nav order (lower = first) | +| `showInNav` | No | Show in navigation menu (default: `true`) | +| `excerpt` | No | Short text for card view | +| `image` | No | Thumbnail for featured card view | +| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | +| `featured` | No | `true` to show in featured section | +| `featuredOrder` | No | Order in featured (lower = first) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `showFooter` | No | Show footer on this page (overrides siteConfig default) | +| `footer` | No | Per-page footer markdown (overrides `footer.md` and siteConfig.defaultContent) | +| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) | +| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | +| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | +| `contactForm` | No | Enable contact form on this page | +| `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment | +| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | +| `docsSectionGroup` | No | Group name for docs sidebar. Pages with the same group name appear together. | +| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | +| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | +| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | +| `docsLanding` | No | Set `true` to use this page as the docs landing page (shown when navigating to `/docs`). | + +## Common patterns + +### Hide pages from navigation + +Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. + +### Unlisted posts + +Set `unlisted: true` to hide a blog post from all listings while keeping it accessible via direct link. Unlisted posts are excluded from: blog listings (`/blog` page), featured sections (homepage), tag pages (`/tags/[tag]`), search results (Command+K), and related posts. The post remains accessible via direct URL (e.g., `/blog/post-slug`). Useful for draft posts, private content, or posts you want to share via direct link only. Note: `unlisted` only works for blog posts, not pages. + +### Show image at top + +Add `showImageAtTop: true` to display the `image` field at the top of the post/page above the header. Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails. + +### Image lightbox + +Images in blog posts and pages automatically open in a full-screen lightbox when clicked (if enabled in `siteConfig.imageLightbox.enabled`). This allows readers to view images at full size. The lightbox can be closed by clicking outside the image, pressing Escape, or clicking the close button. + +### Text alignment + +Use `textAlign` field to control text alignment for page content. Options: `"left"` (default), `"center"`, or `"right"`. Used by `home.md` to control home intro alignment. + +### Docs section + +To add content to the docs sidebar: + +1. Add `docsSection: true` to frontmatter +2. Optionally set `docsSectionGroup` to group related content +3. Use `docsSectionOrder` to control order within groups +4. Use `docsSectionGroupOrder` to control group order +5. Add `docsSectionGroupIcon` for visual icons (Phosphor icons) + +### Docs landing page + +Set `docsLanding: true` on one post or page to make it the docs landing page. This content displays when navigating to `/docs`. \ No newline at end of file diff --git a/public/raw/docs-search.md b/public/raw/docs-search.md new file mode 100644 index 0000000..57c34b3 --- /dev/null +++ b/public/raw/docs-search.md @@ -0,0 +1,224 @@ +# Search + +--- +Type: page +Date: 2026-01-06 +--- + +## Keyword Search + +Keyword search matches exact words using Convex full-text search. Results update instantly as you type. + +For meaning-based search that finds conceptually similar content, see [Semantic Search](/docs-semantic-search). + +--- + +### Keyboard shortcuts + +Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to open search. Click the magnifying glass icon works too. + +| Key | Action | +| --- | ------ | +| `Cmd+K` / `Ctrl+K` | Open/close search | +| `Tab` | Switch between Keyword and Semantic modes | +| `↑` `↓` | Navigate results | +| `Enter` | Select result | +| `Esc` | Close modal | + +### How keyword search works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ KEYWORD SEARCH FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + + ┌──────────┐ ┌─────────────┐ ┌──────────────────┐ + │ Cmd+K │───▶│ SearchModal │───▶│ Convex Query │ + └──────────┘ └─────────────┘ └────────┬─────────┘ + │ + ┌──────────────────────┴──────────────────────┐ + ▼ ▼ + ┌────────────────┐ ┌────────────────┐ + │ search_title │ │ search_content │ + │ (index) │ │ (index) │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └──────────────────┬─────────────────────────┘ + ▼ + ┌─────────────────────┐ + │ Dedupe + Rank │ + │ (title matches first)│ + └──────────┬──────────┘ + ▼ + ┌─────────────────────┐ + │ Search Results │ + │ (max 15) │ + └──────────┬──────────┘ + ▼ + ┌─────────────────────┐ + │ Navigate + ?q=term │ + └──────────┬──────────┘ + ▼ + ┌─────────────────────┐ + │ Highlight matches │ + │ + scroll to first │ + └─────────────────────┘ +``` + +1. User presses `Cmd+K` to open SearchModal +2. SearchModal sends reactive query to Convex as user types +3. Convex searches both title and content indexes in parallel +4. Results are deduplicated (same post can match both indexes) +5. Results ranked with title matches first, limited to 15 +6. User selects result, navigates with `?q=searchterm` param +7. Destination page highlights all matches and scrolls to first + +### Why keyword search + +Keyword search is the default because it's: + +- **Instant** - Results return in milliseconds, no API calls +- **Free** - No external services, no per-query costs +- **Reactive** - Updates in real-time as you type +- **Highlightable** - Matches exact words, so results can be highlighted on the destination page + +Use keyword search when you know the exact terms, code snippets, or commands you're looking for. + +### How search indexes work + +Search indexes are defined in `convex/schema.ts` on the posts and pages tables: + +```typescript +posts: defineTable({ + title: v.string(), + content: v.string(), + published: v.boolean(), + // ... other fields +}) + .searchIndex("search_content", { + searchField: "content", + filterFields: ["published"], + }) + .searchIndex("search_title", { + searchField: "title", + filterFields: ["published"], + }), +``` + +Each table has two search indexes: + +- `search_title` - Searches the title field +- `search_content` - Searches the full markdown content + +The `filterFields: ["published"]` allows filtering to only published content without a separate query. + +### Search query implementation + +The search query in `convex/search.ts` searches both titles and content, then deduplicates and ranks results: + +```typescript +// Search posts by title +const postsByTitle = await ctx.db + .query("posts") + .withSearchIndex("search_title", (q) => + q.search("title", args.query).eq("published", true) + ) + .take(10); + +// Search posts by content +const postsByContent = await ctx.db + .query("posts") + .withSearchIndex("search_content", (q) => + q.search("content", args.query).eq("published", true) + ) + .take(10); +``` + +Key features: + +- **Dual search** - Searches both title and content indexes +- **Filter by published** - Only returns published content +- **Deduplication** - Removes duplicates when a post matches both title and content +- **Ranking** - Title matches sort before content-only matches +- **Snippets** - Generates context snippets around the search term +- **Unlisted filtering** - Excludes posts with `unlisted: true` + +### Frontend search modal + +The `SearchModal` component (`src/components/SearchModal.tsx`) provides the UI: + +```typescript +// Reactive search query - updates as you type +const results = useQuery( + api.search.search, + searchQuery.trim() ? { query: searchQuery } : "skip" +); +``` + +The `"skip"` parameter tells Convex to skip the query when the search field is empty, avoiding unnecessary database calls. + +### Search result highlighting + +When you click a search result, the app navigates to the page with a `?q=` parameter. The `useSearchHighlighting` hook (`src/hooks/useSearchHighlighting.ts`) then: + +1. Waits for page content to load +2. Finds all occurrences of the search term +3. Wraps matches in `` tags with highlight styling +4. Scrolls to the first match +5. Highlights pulse, then fade after 4 seconds +6. Press `Esc` to clear highlights manually + +### Files involved + +| File | Purpose | +| ---- | ------- | +| `convex/schema.ts` | Search index definitions | +| `convex/search.ts` | Search query with deduplication and snippets | +| `src/components/SearchModal.tsx` | Search UI with keyboard navigation | +| `src/components/Layout.tsx` | Keyboard shortcut handler (Cmd+K) | +| `src/hooks/useSearchHighlighting.ts` | Result highlighting and scroll-to-match | + +### Adding search to new tables + +To add search to a new table: + +1. Add a search index in `convex/schema.ts`: + +```typescript +myTable: defineTable({ + title: v.string(), + body: v.string(), + status: v.string(), +}) + .searchIndex("search_body", { + searchField: "body", + filterFields: ["status"], + }), +``` + +2. Create a search query in a Convex function: + +```typescript +const results = await ctx.db + .query("myTable") + .withSearchIndex("search_body", (q) => + q.search("body", searchTerm).eq("status", "published") + ) + .take(10); +``` + +3. Run `npx convex dev` to deploy the new index + +### Limitations + +- Search indexes only work on string fields +- One `searchField` per index (create multiple indexes for multiple fields) +- Filter fields support equality only (not ranges or inequalities) +- Results are ranked by relevance, not by date or other fields +- Maximum 10 results per `.take()` call (paginate for more) + +### Resources + +- [Convex Full-Text Search](https://docs.convex.dev/search/text-search) +- [Search Index API](https://docs.convex.dev/api/classes/server.TableDefinition#searchindex) +- [Semantic Search](/docs-semantic-search) - Vector-based search for finding similar content \ No newline at end of file diff --git a/public/raw/docs-semantic-search.md b/public/raw/docs-semantic-search.md new file mode 100644 index 0000000..d54a11b --- /dev/null +++ b/public/raw/docs-semantic-search.md @@ -0,0 +1,126 @@ +# Semantic Search + +--- +Type: page +Date: 2026-01-06 +--- + +## Semantic Search + +Semantic search finds content by meaning, not exact words. Ask questions naturally and find conceptually related content. + +Press `Cmd+K` then `Tab` to switch to Semantic mode. For exact word matching, see [Keyword Search](/docs-search). + +--- + +### When to use each mode + +| Use case | Mode | +|----------|------| +| "authentication error" (exact term) | Keyword | +| "login problems" (conceptual) | Semantic | +| Find specific code or commands | Keyword | +| "how do I deploy?" (question) | Semantic | +| Need matches highlighted on page | Keyword | +| Not sure of exact terminology | Semantic | + +### How semantic search works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SEMANTIC SEARCH FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐ + │ User query: │───▶│ OpenAI API │───▶│ Query embedding │ + │ "how to │ │ text-embedding- │ │ [0.12, -0.45, │ + │ deploy" │ │ ada-002 │ │ 0.78, ...] │ + └──────────────┘ └─────────────────┘ └────────┬─────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Convex vectorSearch │ + │ Compare to stored │ + │ post/page embeddings│ + └──────────┬──────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Results sorted by │ + │ similarity score │ + │ (0-100%) │ + └─────────────────────┘ +``` + +1. Your query is converted to a vector (1536 numbers) using OpenAI's embedding model +2. Convex compares this vector to stored embeddings for all posts and pages +3. Results are ranked by similarity score (higher = more similar meaning) +4. Top 15 results returned + +### Technical comparison + +| Aspect | Keyword | Semantic | +|--------|---------|----------| +| Speed | Instant | ~300ms | +| Cost | Free | ~$0.0001/query | +| Highlighting | Yes | No | +| API required | No | OpenAI | + +### Configuration + +Semantic search requires an OpenAI API key: + +```bash +npx convex env set OPENAI_API_KEY sk-your-key-here +``` + +If the key is not configured: +- Semantic search returns empty results +- Keyword search continues to work normally +- Sync script skips embedding generation + +### How embeddings are generated + +When you run `npm run sync`: + +1. Content syncs to Convex (posts and pages) +2. Script checks for posts/pages without embeddings +3. For each, combines title + content into text +4. Calls OpenAI to generate 1536-dimension embedding +5. Stores embedding in Convex database + +Embeddings are generated once per post/page. If content changes, a new embedding is generated on the next sync. + +### Files involved + +| File | Purpose | +| ---- | ------- | +| `convex/schema.ts` | `embedding` field and `vectorIndex` on posts/pages | +| `convex/embeddings.ts` | Embedding generation actions | +| `convex/embeddingsQueries.ts` | Queries for posts/pages without embeddings | +| `convex/semanticSearch.ts` | Vector search action | +| `convex/semanticSearchQueries.ts` | Queries for hydrating search results | +| `src/components/SearchModal.tsx` | Mode toggle (Tab to switch) | +| `scripts/sync-posts.ts` | Triggers embedding generation after sync | + +### Limitations + +- **No highlighting**: Semantic search finds meaning, not exact words, so matches can't be highlighted +- **API cost**: Each search query costs ~$0.0001 (embedding generation) +- **Latency**: ~300ms vs instant for keyword search (API round-trip) +- **Requires OpenAI key**: Won't work without `OPENAI_API_KEY` configured +- **Token limit**: Content is truncated to ~8000 characters for embedding + +### Similarity scores + +Results show a percentage score (0-100%): +- **90%+**: Very similar meaning +- **70-90%**: Related content +- **50-70%**: Loosely related +- **<50%**: Weak match (may not be relevant) + +### Resources + +- [Convex Vector Search](https://docs.convex.dev/search/vector-search) +- [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings) +- [Keyword Search](/docs-search) - Full-text search documentation \ No newline at end of file diff --git a/public/raw/documentation.md b/public/raw/documentation.md index e103a52..66db5dc 100644 --- a/public/raw/documentation.md +++ b/public/raw/documentation.md @@ -2,10 +2,10 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- -## Getting Started +## Getting started Reference documentation for setting up, customizing, and deploying this markdown framework. @@ -77,789 +77,6 @@ markdown-site/ └── netlify.toml # Deployment config ``` -## Content - -**Markdown examples:** For complete markdown syntax examples including code blocks, tables, lists, links, images, collapsible sections, and all formatting options, see [Writing Markdown with Code Examples](/markdown-with-code-examples). That post includes copy-paste examples for every markdown feature. - -### 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. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax | -| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) | -| `excerpt` | No | Short text for card view | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | -| `showFooter` | No | Show footer on this post (overrides siteConfig default) | -| `footer` | No | Per-post footer markdown (overrides `footer.md` and siteConfig.defaultContent) | -| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) | -| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | -| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) | -| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | -| `contactForm` | No | Enable contact form on this post | -| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | -| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | -| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | -| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | -| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | -| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | -| `docsLanding` | No | Set `true` to use this post as the docs landing page (shown when navigating to `/docs`). | -| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) | - -### Static pages - -Create files in `content/pages/` with frontmatter: - -```markdown ---- -title: "Page Title" -slug: "url-path" -published: true -order: 1 ---- - -Content here... -``` - -### Frontmatter options - -| Field | Required | Description | -| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `title` | Yes | Nav link text | -| `slug` | Yes | URL path | -| `published` | Yes | `true` to show | -| `order` | No | Nav order (lower = first) | -| `showInNav` | No | Show in navigation menu (default: `true`) | -| `excerpt` | No | Short text for card view | -| `image` | No | Thumbnail for featured card view | -| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) | -| `featured` | No | `true` to show in featured section | -| `featuredOrder` | No | Order in featured (lower = first) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | -| `showFooter` | No | Show footer on this page (overrides siteConfig default) | -| `footer` | No | Per-page footer markdown (overrides `footer.md` and siteConfig.defaultContent) | -| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) | -| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. | -| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | -| `contactForm` | No | Enable contact form on this page | -| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the page above the header (default: `false`) | -| `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment | -| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | -| `docsSectionGroup` | No | Group name for docs sidebar. Pages with the same group name appear together. | -| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | -| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | -| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | -| `docsLanding` | No | Set `true` to use this page as the docs landing page (shown when navigating to `/docs`). | - -**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. - -**Unlisted posts:** Set `unlisted: true` to hide a blog post from all listings while keeping it accessible via direct link. Unlisted posts are excluded from: blog listings (`/blog` page), featured sections (homepage), tag pages (`/tags/[tag]`), search results (Command+K), and related posts. The post remains accessible via direct URL (e.g., `/blog/post-slug`). Useful for draft posts, private content, or posts you want to share via direct link only. Note: `unlisted` only works for blog posts, not pages. - -**Show image at top:** Add `showImageAtTop: true` to display the `image` field at the top of the post/page above the header. Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails. - -**Image lightbox:** Images in blog posts and pages automatically open in a full-screen lightbox when clicked (if enabled in `siteConfig.imageLightbox.enabled`). This allows readers to view images at full size. The lightbox can be closed by clicking outside the image, pressing Escape, or clicking the close button. - -**Text alignment:** Use `textAlign` field to control text alignment for page content. Options: `"left"` (default), `"center"`, or `"right"`. Used by `home.md` to control home intro alignment. - -### Home intro content - -The homepage intro text can be synced from markdown via `content/pages/home.md` (slug: `home-intro`). This allows you to update homepage text without redeploying. - -**Create home intro:** - -1. Create `content/pages/home.md`: - -```markdown ---- -title: "Home Intro" -slug: "home-intro" -published: true -showInNav: false -order: -1 -textAlign: "left" ---- - -Your homepage intro text here. - -## Features - -**Feature one** — Description here. - -**Feature two** — Description here. -``` - -2. Run `npm run sync` to sync to Convex - -3. Content appears on homepage instantly (no rebuild needed) - -**Blog heading styles:** Headings (h1-h6) in home intro content use the same styling as blog posts (`blog-h1` through `blog-h6` classes). Each heading gets an automatic ID and a clickable anchor link (#) that appears on hover. Lists, blockquotes, horizontal rules, and links also use blog styling classes for consistent typography. - -**Fallback:** If `home-intro` page is not found, the homepage falls back to `siteConfig.bio` text. - -### Footer content - -The footer content can be synced from markdown via `content/pages/footer.md` (slug: `footer`). This allows you to update footer text without touching code. - -**Create footer content:** - -1. Create `content/pages/footer.md`: - -```markdown ---- -title: "Footer" -slug: "footer" -published: true -showInNav: false -order: -1 ---- - -Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). - -Created by [Your Name](https://x.com/yourhandle). Follow on [Twitter/X](https://x.com/yourhandle) and [GitHub](https://github.com/yourusername). -``` - -2. Run `npm run sync` to sync to Convex - -3. Footer content appears on homepage, blog page, and all posts/pages instantly (no rebuild needed) - -**Markdown support:** Footer content supports full markdown including links, paragraphs, line breaks, and images. External links automatically open in new tabs. - -**Fallback:** If `footer` page is not found, the footer falls back to `siteConfig.footer.defaultContent`. - -**Priority order:** Per-post/page frontmatter `footer:` field (custom override) > synced footer.md content > siteConfig.footer.defaultContent. - -**Relationship with siteConfig:** The `content/pages/footer.md` page takes priority over `siteConfig.footer.defaultContent` when present. Use the markdown page for dynamic content that changes frequently, or keep using siteConfig for static footer content. - -### Sidebar layout - -Posts and pages can use a docs-style layout with a table of contents sidebar. Add `layout: "sidebar"` to the frontmatter: - -```markdown ---- -title: "Documentation" -slug: "docs" -published: true -layout: "sidebar" ---- - -# Introduction - -## Section One - -### Subsection - -## Section Two -``` - -**Features:** - -- Left sidebar displays table of contents extracted from H1, H2, H3 headings -- Two-column layout: 220px sidebar + flexible content area -- Sidebar only appears if headings exist in the content -- Active heading highlighting as you scroll -- Smooth scroll navigation when clicking TOC links -- Mobile responsive: stacks to single column below 1024px -- Works for both blog posts and static pages - -The sidebar extracts headings automatically from your markdown content. No manual TOC needed. - -### Right sidebar - -When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. - -**Configuration:** - -Enable globally in `src/config/siteConfig.ts`: - -```typescript -rightSidebar: { - enabled: true, // Set to false to disable right sidebar globally - minWidth: 1135, // Minimum viewport width to show sidebar -}, -``` - -Control per post/page with frontmatter: - -```markdown ---- -title: "My Post" -rightSidebar: true # Enable right sidebar for this post ---- -``` - -**Features:** - -- Right sidebar appears at 1135px+ viewport width -- Contains CopyPageDropdown with all sharing options -- Three-column layout: left sidebar (TOC), main content, right sidebar -- CopyPageDropdown automatically moves from nav to right sidebar when enabled -- Hidden below 1135px breakpoint, CopyPageDropdown returns to nav -- Per-post/page control via `rightSidebar: true` frontmatter field -- Opt-in only: right sidebar only appears when explicitly enabled in frontmatter - -**Use cases:** - -- Keep CopyPageDropdown accessible on wide screens without cluttering the nav -- Provide quick access to sharing options while reading long content -- Works alongside left sidebar TOC for comprehensive navigation - -**Example for blog post:** - -```markdown ---- -title: "My Tutorial" -description: "A detailed guide" -date: "2025-01-20" -slug: "my-tutorial" -published: true -tags: ["tutorial"] -layout: "sidebar" ---- - -# Introduction - -## Getting Started - -### Prerequisites - -## Advanced Topics -``` - -### How frontmatter works - -Frontmatter is the YAML metadata at the top of each markdown file between `---` markers. Here is how it flows through the system: - -**Content directories:** - -- `content/blog/*.md` contains blog posts with frontmatter -- `content/pages/*.md` contains static pages with frontmatter - -**Processing flow:** - -1. Markdown files in `content/blog/` and `content/pages/` contain YAML frontmatter -2. `scripts/sync-posts.ts` uses `gray-matter` to parse frontmatter and validate required fields -3. Parsed data is sent to Convex mutations (`api.posts.syncPostsPublic`, `api.pages.syncPagesPublic`) -4. `convex/schema.ts` defines the database structure for storing the data - -**Adding a new frontmatter field:** - -To add a custom frontmatter field, update these files: - -1. The interface in `scripts/sync-posts.ts` (`PostFrontmatter` or `PageFrontmatter`) -2. The parsing logic in `parseMarkdownFile()` or `parsePageFile()` functions -3. The schema in `convex/schema.ts` -4. The sync mutation in `convex/posts.ts` or `convex/pages.ts` - -### Syncing content - -**Development:** - -```bash -npm run sync # Sync markdown content -npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt) -npm run sync:all # Sync content + discovery files together -``` - -**Production:** - -```bash -npm run sync:prod # Sync markdown content -npm run sync:discovery:prod # Update discovery files -npm run sync:all:prod # Sync content + discovery files together -``` - -**Sync everything together:** - -```bash -npm run sync:all # Development: content + discovery -npm run sync:all:prod # Production: content + discovery -``` - -### 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) | -| Site config changes | `npm run sync:discovery` | Updates discovery files | -| Import external URL | `npm run import` then sync | Instant (no rebuild) | -| Images in `public/images/` | Git commit + push | Requires rebuild | -| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild | -| Logo gallery config | Redeploy | Requires rebuild | -| React components/styles | Redeploy | Requires rebuild | - -**Markdown content** syncs instantly to Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild. - -## Configuration - -### Fork configuration - -After forking, you have two options to configure your site: - -**Option 1: Automated (Recommended)** - -```bash -cp fork-config.json.example fork-config.json -# Edit fork-config.json with your site information -npm run configure -``` - -This updates all 11 configuration files in one command. See `FORK_CONFIG.md` for the full JSON schema and options. - -**Option 2: Manual** - -Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually. - -### Files updated by configuration - -| File | What to update | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `src/config/siteConfig.ts` | Site name, title, intro, bio, blog page, logo gallery, GitHub contributions, right sidebar configuration | -| `src/pages/Home.tsx` | Intro paragraph text, footer links | -| `convex/http.ts` | `SITE_URL`, `SITE_NAME`, description strings (3 locations) | -| `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, topics | -| `public/robots.txt` | Sitemap URL and header comment | -| `public/openapi.yaml` | API title, server URL, site name in examples | -| `public/.well-known/ai-plugin.json` | Site name, descriptions | -| `src/config/siteConfig.ts` | Default theme (`defaultTheme` field) | - -### Site title and description metadata - -These files contain the main site description text. Update them with your own tagline: - -| File | What to change | -| --------------------------------- | -------------------------------------------------------------- | -| `index.html` | meta description, og:description, twitter:description, JSON-LD | -| `README.md` | Main description at top of file | -| `src/config/siteConfig.ts` | name, title, and bio fields | -| `src/pages/Home.tsx` | Intro paragraph (hardcoded JSX with links) | -| `convex/http.ts` | SITE_NAME constant and description strings (3 locations) | -| `convex/rss.ts` | SITE_TITLE and SITE_DESCRIPTION constants | -| `public/llms.txt` | Header quote, Name, and Description fields | -| `public/openapi.yaml` | API title and example site name | -| `AGENTS.md` | Project overview section | -| `content/blog/about-this-blog.md` | Title, description, excerpt, and opening paragraph | -| `content/pages/about.md` | excerpt field and opening paragraph | -| `content/pages/docs.md` | Opening description paragraph | - -**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. - -### Site settings - -Edit `src/config/siteConfig.ts`: - -```typescript -export default { - name: "Site Name", - title: "Tagline", - logo: "/images/logo.svg", // null to hide homepage logo - intro: "Introduction text...", - bio: "Bio text...", - - // Blog page configuration - blogPage: { - enabled: true, // Enable /blog route - showInNav: true, // Show in navigation - title: "Blog", // Nav link and page title - order: 0, // Nav order (lower = first) - }, - - // Hardcoded navigation items for React routes - hardcodedNavItems: [ - { - slug: "stats", - title: "Stats", - order: 10, - showInNav: true, // Set to false to hide from nav - }, - { - slug: "write", - title: "Write", - order: 20, - showInNav: true, - }, - ], - - // Inner page logo configuration - innerPageLogo: { - enabled: true, // Set to false to hide logo on inner pages - size: 28, // Logo height in pixels (keeps aspect ratio) - }, - - // Featured section - featuredViewMode: "list", // 'list' or 'cards' - showViewToggle: true, - - // Logo gallery (static grid or scrolling marquee) - logoGallery: { - enabled: true, // false to hide - images: [{ src: "/images/logos/logo.svg", href: "https://example.com" }], - position: "above-footer", - speed: 30, - title: "Built with", - scrolling: false, // false = static grid, true = scrolling marquee - maxItems: 4, // Number of logos when scrolling is false - }, - - links: { - docs: "/docs", - convex: "https://convex.dev", - }, -}; -``` - -**Logo configuration:** - -- `logo`: Homepage logo path (set to `null` to hide). Uses `public/images/logo.svg` by default. -- `innerPageLogo`: Logo shown on blog page, posts, and static pages. Desktop: top left. Mobile: top right. Set `enabled: false` to hide on inner pages while keeping homepage logo. - -**Navigation structure:** - -Navigation combines three sources sorted by `order`: - -1. Blog link (if `blogPage.enabled` and `blogPage.showInNav` are true) -2. Hardcoded nav items (React routes from `hardcodedNavItems`) -3. Markdown pages (from `content/pages/` with `showInNav: true`) - -All items sort by `order` (lower first), then alphabetically by title. - -### 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` or `npm run sync:all`. 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 -}; -``` - -### GitHub contributions graph - -Display your GitHub contribution activity on the homepage. Configure in `siteConfig`: - -```typescript -gitHubContributions: { - enabled: true, // Set to false to hide - username: "yourusername", // Your GitHub username - showYearNavigation: true, // Show arrows to navigate between years - linkToProfile: true, // Click graph to open GitHub profile - title: "GitHub Activity", // Optional title above the graph -}, -``` - -| Option | Description | -| -------------------- | -------------------------------------- | -| `enabled` | `true` to show, `false` to hide | -| `username` | Your GitHub username | -| `showYearNavigation` | Show prev/next year navigation | -| `linkToProfile` | Click graph to visit GitHub profile | -| `title` | Text above graph (`undefined` to hide) | - -Theme-aware colors match each site theme. Uses public API (no GitHub token required). - -### Visitor map - -Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored. - -```typescript -visitorMap: { - enabled: true, // Set to false to hide - title: "Live Visitors", // Optional title above the map -}, -``` - -| Option | Description | -| --------- | ------------------------------------ | -| `enabled` | `true` to show, `false` to hide | -| `title` | Text above map (`undefined` to hide) | - -The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge. - -### Logo gallery - -The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. - -```typescript -// In src/config/siteConfig.ts -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: "Built with", // undefined to hide - scrolling: false, // false = static grid, true = scrolling marquee - maxItems: 4, // Number of logos when scrolling is false -}, -``` - -| 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) | -| `scrolling` | `true` for infinite scroll, `false` for static grid | -| `maxItems` | Max logos to show when `scrolling` is `false` (default: 4) | - -**Display modes:** - -- `scrolling: true`: Infinite horizontal scroll with all logos -- `scrolling: false`: Static centered grid showing first `maxItems` logos - -**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. - -### Blog page - -The site supports a dedicated blog page at `/blog` with two view modes: list view (year-grouped posts) and card view (thumbnail grid). Configure in `src/config/siteConfig.ts`: - -```typescript -blogPage: { - enabled: true, // Enable /blog route - showInNav: true, // Show in navigation - title: "Blog", // Nav link and page title - order: 0, // Nav order (lower = first) - viewMode: "list", // Default view: "list" or "cards" - showViewToggle: true, // Show toggle button to switch views -}, -displayOnHomepage: true, // Show posts on homepage -``` - -| Option | Description | -| ------------------- | -------------------------------------- | -| `enabled` | Enable the `/blog` route | -| `showInNav` | Show Blog link in navigation | -| `title` | Text for nav link and page heading | -| `order` | Position in navigation (lower = first) | -| `viewMode` | Default view: `"list"` or `"cards"` | -| `showViewToggle` | Show toggle button to switch views | -| `displayOnHomepage` | Show post list on homepage | - -**View modes:** - -- **List view:** Year-grouped posts with titles, read time, and dates -- **Card view:** Grid of cards showing thumbnails, titles, excerpts, and metadata - -**Card view details:** - -Cards display post thumbnails (from `image` frontmatter field), titles, excerpts (or descriptions), read time, and dates. Posts without images show cards without thumbnail areas. Grid is responsive: 3 columns on desktop, 2 on tablet, 1 on mobile. - -**Display options:** - -- Homepage only: `displayOnHomepage: true`, `blogPage.enabled: false` -- Blog page only: `displayOnHomepage: false`, `blogPage.enabled: true` -- Both: `displayOnHomepage: true`, `blogPage.enabled: true` - -**Navigation order:** The Blog link merges with page links and sorts by order. Pages use the `order` field in frontmatter. Set `blogPage.order: 5` to position Blog after pages with order 0-4. - -**View preference:** User's view mode choice is saved to localStorage and persists across page visits. - -### Scroll-to-top button - -A scroll-to-top button appears after scrolling down. Configure in `src/components/Layout.tsx`: - -```typescript -const scrollToTopConfig: Partial = { - 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`. - -Configure in `src/config/siteConfig.ts`: - -```typescript -export const siteConfig: SiteConfig = { - // ... other config - defaultTheme: "tan", -}; -``` - -### Font - -Configure the font in `src/config/siteConfig.ts`: - -```typescript -export const siteConfig: SiteConfig = { - // ... other config - fontFamily: "serif", // Options: "serif", "sans", or "monospace" -}; -``` - -Or edit `src/styles/global.css` directly: - -```css -body { - /* Sans-serif */ - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - - /* Serif (default) */ - font-family: "New York", ui-serif, Georgia, serif; - - /* Monospace */ - font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace; -} -``` - -Available options: `serif` (default), `sans`, or `monospace`. - -### Font Sizes - -All font sizes use CSS variables in `:root`. Customize by editing: - -```css -:root { - --font-size-base: 16px; - --font-size-sm: 13px; - --font-size-lg: 17px; - --font-size-blog-content: 17px; - --font-size-post-title: 32px; -} -``` - -Mobile sizes defined in `@media (max-width: 768px)` block. - -### 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 | - -**Images require git deploy.** Images are served as static files from your repository, not synced to Convex. After adding images to `public/images/`: - -1. Commit the image files to git -2. Push to GitHub -3. Wait for Netlify to rebuild - -The `npm run sync` command only syncs markdown text content. Images are deployed when Netlify builds your site. Use `npm run sync:discovery` to update discovery files (AGENTS.md, llms.txt) when site configuration changes. - -**Adding images to posts:** You can add images using markdown syntax `![alt](src)` or HTML `` tags. The site uses `rehypeRaw` and `rehypeSanitize` to safely render HTML in markdown content. See [Using Images in Blog Posts](/using-images-in-posts) for complete examples and best practices. - -**Logo options:** - -- **Homepage logo:** Configured via `logo` in `siteConfig.ts`. Set to `null` to hide. -- **Inner page logo:** Configured via `innerPageLogo` in `siteConfig.ts`. Shows on blog page, posts, and static pages. Desktop: top left corner. Mobile: top right corner (smaller). Set `enabled: false` to hide on inner pages while keeping homepage logo. - -## Tag Pages and Related Posts - -Tag pages are available at `/tags/[tag]` for each tag used in your posts. They display all posts with that tag in a list or card view with localStorage persistence for view mode preference. - -**Related posts:** Individual blog posts show up to 3 related posts in the footer based on shared tags. Posts are sorted by relevance (number of shared tags) then by date. Only appears on blog posts (not static pages). - -**Tag links:** Tags in post footers link to their respective tag archive pages. - ## 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. @@ -874,16 +91,6 @@ 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: @@ -910,9 +117,9 @@ Each post and page includes a share dropdown with options: **Download as SKILL.md:** Downloads the content formatted as an Anthropic Agent Skills file with metadata, triggers, and instructions sections. -## Homepage Post Limit +## Homepage post limit -Limit the number of posts shown on the homepage: +Limit the number of posts shown on the homepage. Configure in `src/config/siteConfig.ts`: ```typescript postsDisplay: { @@ -928,50 +135,6 @@ postsDisplay: { When posts are limited, an optional "read more" link appears below the list. Only shows when there are more posts than the limit. -## Blog Page Featured Layout - -Posts can be marked as featured on the blog page using the `blogFeatured` frontmatter field: - -```yaml ---- -title: "My Featured Post" -blogFeatured: true ---- -``` - -The first `blogFeatured` post displays as a hero card with landscape image, tags, date, title, excerpt, author info, and read more link. Remaining `blogFeatured` posts display in a 2-column featured row with excerpts. Regular (non-featured) posts display in a 3-column grid without excerpts. - -## Homepage Post Limit - -Limit the number of posts shown on the homepage: - -```typescript -postsDisplay: { - showOnHome: true, - homePostsLimit: 5, // Limit to 5 most recent posts (undefined = show all) - homePostsReadMore: { - enabled: true, - text: "Read more blog posts", - link: "/blog", - }, -}, -``` - -When posts are limited, an optional "read more" link appears below the list. Only shows when there are more posts than the limit. - -## Blog Page Featured Layout - -Posts can be marked as featured on the blog page using the `blogFeatured` frontmatter field: - -```yaml ---- -title: "My Featured Post" -blogFeatured: true ---- -``` - -The first `blogFeatured` post displays as a hero card with landscape image, tags, date, title, excerpt, author info, and read more link. Remaining `blogFeatured` posts display in a 2-column featured row with excerpts. Regular (non-featured) posts display in a 3-column grid without excerpts. - ## Real-time stats The `/stats` page displays real-time analytics: @@ -983,6 +146,15 @@ The `/stats` page displays real-time analytics: All stats update automatically via Convex subscriptions. +**Configuration:** Enable or disable in `src/config/siteConfig.ts`: + +```typescript +statsPage: { + enabled: true, // Enable /stats route + showInNav: false, // Hide from navigation (access via direct URL) +}, +``` + ## Newsletter Admin The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters. @@ -1002,9 +174,7 @@ The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subs - Retention rate - Detailed summary table -**Configuration:** - -Enable in `src/config/siteConfig.ts`: +**Configuration:** Enable in `src/config/siteConfig.ts`: ```typescript newsletterAdmin: { @@ -1057,247 +227,6 @@ npm run newsletter:send setup-guide The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results. -## Dashboard - -The Dashboard at `/dashboard` provides a centralized UI for managing content, configuring the site, and performing sync operations. It's designed for developers who fork the repository to set up and manage their markdown blog. - -**Access:** Navigate to `/dashboard` in your browser. The dashboard is not linked in the navigation by default (similar to Newsletter Admin pattern). - -**Authentication:** WorkOS authentication is optional. Configure it in `siteConfig.ts`: - -```typescript -dashboard: { - enabled: true, - requireAuth: false, // Set to true to require WorkOS authentication -}, -``` - -When `requireAuth` is `false`, the dashboard is open access. When `requireAuth` is `true` and WorkOS is configured, users must log in to access the dashboard. See [How to setup WorkOS](https://www.markdown.fast/how-to-setup-workos) for authentication setup. - -### Content Management - -**Posts and Pages List Views:** - -- View all posts and pages (published and unpublished) -- Filter by status: All, Published, Drafts -- Search by title or content -- Pagination with "First" and "Next" buttons -- Items per page selector (15, 25, 50, 100) - default: 15 -- Edit, view, and publish/unpublish options -- WordPress-style UI with date, edit, view, and publish controls - -**Post and Page Editor:** - -- Markdown editor with live preview -- Frontmatter sidebar on the right with all available fields -- Draggable/resizable frontmatter sidebar (200px-600px width) -- Independent scrolling for frontmatter sidebar -- Preview mode shows content as it appears on the live site -- Download markdown button to generate `.md` files -- Copy markdown to clipboard -- All frontmatter fields editable in sidebar -- Preview uses ReactMarkdown with proper styling - -**Write Post and Write Page:** - -- Full-screen writing interface -- Markdown editor with word/line/character counts -- Frontmatter reference panel -- Download markdown button for new content -- Content persists in localStorage -- Separate storage for post and page content - -### AI Agent - -The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat and Image Generation. - -**Chat Tab:** - -- Multi-model selector: Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash -- Per-session chat history stored in Convex -- Markdown rendering for AI responses -- Copy functionality for AI responses -- Lazy API key validation (errors only shown when user tries to use a specific model) - -**Image Tab:** - -- AI image generation with two models: - - Nano Banana (gemini-2.0-flash-exp-image-generation) - Experimental model - - Nano Banana Pro (imagen-3.0-generate-002) - Production model -- Aspect ratio selection: 1:1, 16:9, 9:16, 4:3, 3:4 -- Images stored in Convex storage with session tracking -- Gallery view of recent generated images - -**Environment Variables (Convex):** - -| Variable | Description | -| ------------------- | -------------------------------------------------- | -| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 | -| `OPENAI_API_KEY` | Required for GPT-4o | -| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | - -**Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model. - -### Newsletter Management - -All Newsletter Admin features integrated into the Dashboard: - -- **Subscribers:** View, search, filter, and delete subscribers -- **Send Newsletter:** Select a blog post to send as newsletter -- **Write Email:** Compose custom emails with markdown support -- **Recent Sends:** View last 10 newsletter sends (posts and custom emails) -- **Email Stats:** Dashboard with total emails, newsletters sent, active subscribers, retention rate - -All newsletter sections are full-width in the dashboard content area. - -### Content Import - -**Firecrawl Import:** - -- Import articles from external URLs using Firecrawl API -- Requires `FIRECRAWL_API_KEY` in `.env.local` -- Creates local markdown drafts in `content/blog/` -- Imported posts are drafts (`published: false`) by default -- Review, edit, set `published: true`, then sync - -### Site Configuration - -**Config Generator:** - -- UI to configure all settings in `src/config/siteConfig.ts` -- Generates downloadable `siteConfig.ts` file -- Hybrid approach: dashboard generates config, file-based config continues to work -- Includes all site configuration options: - - Site name, title, logo, bio, intro - - Blog page settings - - Featured section configuration - - Logo gallery settings - - GitHub contributions - - Footer and social footer - - Newsletter settings - - Contact form settings - - Stats page settings - - And more - -**Index HTML Editor:** - -- View and edit `index.html` content -- Meta tags, Open Graph, Twitter Cards, JSON-LD -- Download updated HTML file - -### Analytics - -- Real-time stats dashboard (clone of `/stats` page) -- Active visitors with per-page breakdown -- Total page views and unique visitors -- Views by page sorted by popularity -- Does not follow `siteConfig.statsPage` settings (always accessible in dashboard) - -### Sync Commands - -**Sync Content Section:** - -- UI with buttons for all sync operations -- Development sync commands: - - `npm run sync` - Sync markdown content - - `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) - - `npm run sync:all` - Sync content + discovery files together -- Production sync commands: - - `npm run sync:prod` - Sync markdown content - - `npm run sync:discovery:prod` - Update discovery files - - `npm run sync:all:prod` - Sync content + discovery files together -- Server status indicator shows if sync server is online -- Copy and Execute buttons for each command -- Real-time terminal output when sync server is running -- Command modal shows full command output when sync server is offline -- Toast notifications for success/error feedback - -**Sync Server:** - -- Local HTTP server for executing commands from dashboard -- Start with `npm run sync-server` (runs on localhost:3001) -- Execute commands directly from dashboard with real-time output streaming -- Optional token authentication via `SYNC_TOKEN` environment variable -- Whitelisted commands only for security -- Health check endpoint for server availability detection -- Copy icons for `npm run sync-server` command in dashboard - -**Header Sync Buttons:** - -- Quick sync buttons in dashboard header (right side) -- `npm run sync:all` (dev) button -- `npm run sync:all:prod` (prod) button -- One-click sync for all content and discovery files -- Automatically use sync server when available, fallback to command modal - -### Dashboard Features - -**Search:** - -- Search bar in header -- Search dashboard features, page titles, and post content -- Real-time results as you type - -**Theme and Font:** - -- Theme toggle (dark, light, tan, cloud) -- Font switcher (serif, sans, monospace) -- Preferences persist across sessions - -**Mobile Responsive:** - -- Fully responsive design -- Mobile-optimized layout -- Touch-friendly controls -- Collapsible sidebar on mobile - -**Toast Notifications:** - -- Success, error, info, and warning notifications -- Auto-dismiss after 4 seconds -- Theme-aware styling -- No browser default alerts - -**Command Modal:** - -- Shows sync command output -- Copy command to clipboard -- Close button to dismiss -- Theme-aware styling - -### Technical Details - -- Uses Convex queries for real-time data -- All mutations follow Convex best practices (idempotent, indexed queries) -- Frontmatter sidebar width persisted in localStorage -- Editor content persisted in localStorage -- Independent scrolling for editor and sidebar sections -- Preview uses ReactMarkdown with remark-gfm, remark-breaks, rehype-raw, rehype-sanitize - -### Sync Commands Reference - -**Development:** - -- `npm run sync` - Sync markdown content to development Convex -- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data -- `npm run sync:all` - Run both content sync and discovery sync (development) - -**Production:** - -- `npm run sync:prod` - Sync markdown content to production Convex -- `npm run sync:discovery:prod` - Update discovery files with production data -- `npm run sync:all:prod` - Run both content sync and discovery sync (production) - -**Sync Server:** - -- `npm run sync-server` - Start local HTTP server for executing sync commands from dashboard UI - -**Content Import:** - -- `npm run import ` - Import external URL as markdown post (requires FIRECRAWL_API_KEY) - -**Note:** The dashboard provides a UI for these commands. When the sync server is running (`npm run sync-server`), you can execute commands directly from the dashboard with real-time output. Otherwise, the dashboard shows commands in a modal for copying to your terminal. - ## API endpoints | Endpoint | Description | @@ -1387,13 +316,13 @@ All markdown features work with all four themes and are styled to match the site ## Import external content -Use Firecrawl to import articles from external URLs: +Use Firecrawl to import articles from external URLs. See [How to Use Firecrawl](/how-to-use-firecrawl) for detailed setup instructions. ```bash npm run import https://example.com/article ``` -Setup: +**Quick setup:** 1. Get an API key from firecrawl.dev 2. Add `FIRECRAWL_API_KEY=fc-xxx` to `.env.local` @@ -1408,96 +337,4 @@ The import command creates local markdown files only. It does not interact with 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) - authorName: v.optional(v.string()), // Author display name - authorImage: v.optional(v.string()), // Author avatar image URL - 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) - authorName: v.optional(v.string()), // Author display name - authorImage: v.optional(v.string()), // Author avatar image URL - 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 -- Use `npm run sync:all` or `npm run sync:all:prod` to sync content and update discovery files together -- 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+) \ No newline at end of file +Imported posts are drafts (`published: false`). Review, edit, set `published: true`, then sync. \ No newline at end of file diff --git a/public/raw/footer.md b/public/raw/footer.md index c0a5d1a..12fd6ca 100644 --- a/public/raw/footer.md +++ b/public/raw/footer.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats). diff --git a/public/raw/home-intro.md b/public/raw/home-intro.md index fe4b068..b48fc2a 100644 --- a/public/raw/home-intro.md +++ b/public/raw/home-intro.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**. @@ -27,4 +27,6 @@ agents. --> **Real-time team sync** — Multiple developers run npm run sync from different machines. -**Sync Commands** - Sync discovery commands to update AGENTS.md, CLAUDE.md, and llms.txt \ No newline at end of file +**Sync Commands** - Sync discovery commands to update AGENTS.md, CLAUDE.md, and llms.txt + +**Semantic search** - Find content by meaning, not just keywords, using vector embeddings. \ No newline at end of file diff --git a/public/raw/index.md b/public/raw/index.md index 40bb664..99f8746 100644 --- a/public/raw/index.md +++ b/public/raw/index.md @@ -24,6 +24,8 @@ agents. --> **Sync Commands** - Sync discovery commands to update AGENTS.md, CLAUDE.md, and llms.txt +**Semantic search** - Find content by meaning, not just keywords, using vector embeddings. + --- ## Blog Posts (18) @@ -34,7 +36,7 @@ agents. --> - Date: 2025-12-29 | Reading time: 10 min read | Tags: workos, authentication, tutorial, dashboard - **[How to use the Markdown sync dashboard](/raw/how-to-use-the-markdown-sync-dashboard.md)** - Learn how to use the dashboard at /dashboard to manage content, configure your site, and sync markdown files without leaving your browser. - Date: 2025-12-29 | Reading time: 8 min read | Tags: dashboard, tutorial, content-management -- **[Team Workflows with Git Version Control](/raw/team-workflows-git-version-control.md)** - How teams collaborate on markdown content using git, sync to shared Convex deployments, and automate production syncs with CI/CD. +- **[Team Workflows](/raw/team-workflows-git-version-control.md)** - How teams collaborate on markdown content using git, sync to shared Convex deployments, and automate production syncs with CI/CD. - Date: 2025-12-29 | Reading time: 6 min read | Tags: git, convex, ci-cd, collaboration, workflow - **[How to Use the MCP Server with MarkDown Sync](/raw/how-to-use-mcp-server.md)** - Guide to using the HTTP-based Model Context Protocol(MCP) server at www.markdown.fast/mcp with Cursor and other AI tools - Date: 2025-12-28 | Reading time: 5 min read | Tags: mcp, cursor, ai, tutorial, netlify @@ -58,27 +60,34 @@ agents. --> - Date: 2025-12-14 | Reading time: 3 min read | Tags: tutorial, markdown, cursor, IDE, publishing - **[Writing Markdown with Code Examples](/raw/markdown-with-code-examples.md)** - A complete reference for writing markdown with links, code blocks, images, tables, and formatting. Copy examples directly into your posts. - Date: 2025-12-14 | Reading time: 5 min read | Tags: markdown, tutorial, code -- **[Netlify edge functions blocking AI crawlers from static files](/raw/netlify-edge-excludedpath-ai-crawlers.md)** - Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works. - - Date: 2025-12-14 | Reading time: 5 min read | Tags: netlify, edge-functions, ai, troubleshooting, help -- **[Setup Guide - Fork and Deploy Your Own Markdown Framework](/raw/setup-guide.md)** - Step-by-step guide to fork this markdown sync framework, set up Convex backend, and deploy to Netlify in under 10 minutes. +- **[How we fixed AI crawlers blocked by Netlify edge functions](/raw/netlify-edge-excludedpath-ai-crawlers.md)** - ChatGPT and Perplexity couldn't fetch /raw/*.md files on Netlify. The fix: Content-Type headers. Here's what we tried and what actually worked. + - Date: 2025-12-14 | Reading time: 5 min read | Tags: netlify, edge-functions, ai, troubleshooting +- **[Setup Guide](/raw/setup-guide.md)** - Step-by-step guide to fork this markdown sync framework, set up Convex backend, and deploy to Netlify in under 10 minutes. - Date: 2025-12-14 | Reading time: 8 min read | Tags: convex, netlify, tutorial, deployment - **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts. - Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph -## Pages (8) +## Pages (15) - **[Footer](/raw/footer.md)** - **[Home Intro](/raw/home-intro.md)** - **[Documentation](/raw/documentation.md)** - **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. +- **[Content](/raw/docs-content.md)** +- **[Search](/raw/docs-search.md)** +- **[Semantic Search](/raw/docs-semantic-search.md)** +- **[Frontmatter](/raw/docs-frontmatter.md)** - **[Projects](/raw/projects.md)** - **[Contact](/raw/contact.md)** +- **[Configuration](/raw/docs-configuration.md)** - **[Changelog](/raw/changelog.md)** +- **[Dashboard](/raw/docs-dashboard.md)** +- **[Deployment](/raw/docs-deployment.md)** - **[Newsletter](/raw/newsletter.md)** --- -**Total Content:** 18 posts, 8 pages +**Total Content:** 18 posts, 15 pages All content is available as raw markdown files at `/raw/{slug}.md` diff --git a/public/raw/netlify-edge-excludedpath-ai-crawlers.md b/public/raw/netlify-edge-excludedpath-ai-crawlers.md index d3bf806..2733a67 100644 --- a/public/raw/netlify-edge-excludedpath-ai-crawlers.md +++ b/public/raw/netlify-edge-excludedpath-ai-crawlers.md @@ -1,17 +1,32 @@ -# Netlify edge functions blocking AI crawlers from static files +# How we fixed AI crawlers blocked by Netlify edge functions -> Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works. +> ChatGPT and Perplexity couldn't fetch /raw/*.md files on Netlify. The fix: Content-Type headers. Here's what we tried and what actually worked. --- Type: post Date: 2025-12-14 Reading time: 5 min read -Tags: netlify, edge-functions, ai, troubleshooting, help +Tags: netlify, edge-functions, ai, troubleshooting --- +## The fix + +Add explicit `Content-Type` headers for your raw markdown files in `netlify.toml`: + +```toml +[[headers]] + for = "/raw/*" + [headers.values] + Content-Type = "text/plain; charset=utf-8" + Access-Control-Allow-Origin = "*" + Cache-Control = "public, max-age=3600" +``` + +Thanks to [KP](https://x.com/thisiskp_) for pointing us in the right direction. + ## The problem -AI crawlers cannot access static markdown files at `/raw/*.md` on Netlify, even with `excludedPath` configured. ChatGPT and Perplexity return errors. Claude works. +AI crawlers could not access static markdown files at `/raw/*.md` on Netlify, even with `excludedPath` configured. ChatGPT and Perplexity returned errors. Claude worked. ## What we're building @@ -164,15 +179,19 @@ The core issue appears to be how ChatGPT and Perplexity fetch URLs. Their tools 2. The edge function exclusions work for browsers but not for AI fetch tools 3. There may be rate limiting or bot protection enabled by default -## Current workaround +## Why Content-Type matters -Users can still share content with AI tools by: +Without an explicit `Content-Type` header, Netlify serves files based on extension. The `.md` extension gets served as `text/markdown` or similar, which AI fetch tools may reject or misinterpret. -1. **Copy page** copies markdown to clipboard, then paste into any AI -2. **View as Markdown** opens the raw `.md` file in a browser tab for manual copying -3. **Download as SKILL.md** downloads in Anthropic Agent Skills format +Setting `Content-Type = "text/plain; charset=utf-8"` tells the CDN and AI crawlers exactly what to expect. The `Access-Control-Allow-Origin = "*"` header ensures cross-origin requests work. -The direct "Open in ChatGPT/Claude/Perplexity" buttons have been disabled since the URLs don't work reliably. +## What works now + +Users can share content with AI tools via: + +1. **Copy page** copies markdown to clipboard +2. **View as Markdown** opens the raw `.md` file in browser +3. **Open in ChatGPT/Claude/Perplexity** sends the URL directly (now working) ## Working features diff --git a/public/raw/newsletter.md b/public/raw/newsletter.md index 197e7bc..c7a7406 100644 --- a/public/raw/newsletter.md +++ b/public/raw/newsletter.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- # Newsletter Demo Page diff --git a/public/raw/projects.md b/public/raw/projects.md index a17c3fa..fbb9f7d 100644 --- a/public/raw/projects.md +++ b/public/raw/projects.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-04 +Date: 2026-01-06 --- This markdown framework is open source and built to be extended. Here is what ships out of the box. diff --git a/public/raw/setup-guide.md b/public/raw/setup-guide.md index 6475097..d66a635 100644 --- a/public/raw/setup-guide.md +++ b/public/raw/setup-guide.md @@ -1,4 +1,4 @@ -# Setup Guide - Fork and Deploy Your Own Markdown Framework +# Setup Guide > Step-by-step guide to fork this markdown sync framework, set up Convex backend, and deploy to Netlify in under 10 minutes. @@ -337,29 +337,29 @@ 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) | -| `authorName` | No | Author display name shown next to date | -| `authorImage` | No | Round author avatar image URL | -| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | -| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | -| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | -| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | -| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | -| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | -| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | -| `docsLanding` | No | Set `true` to use as the docs landing page (shown when navigating to `/docs`). | +| 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) | +| `authorName` | No | Author display name shown next to date | +| `authorImage` | No | Round author avatar image URL | +| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | +| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | +| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. | +| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. | +| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. | +| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. | +| `docsSectionGroupIcon` | No | Phosphor icon name for docs sidebar group (e.g., "Rocket", "Book", "PuzzlePiece"). Icon appears left of the group title. See [Phosphor Icons](https://phosphoricons.com) for available icons. | +| `docsLanding` | No | Set `true` to use as the docs landing page (shown when navigating to `/docs`). | ### How Frontmatter Works diff --git a/public/raw/team-workflows-git-version-control.md b/public/raw/team-workflows-git-version-control.md index 206e05f..ebfdd31 100644 --- a/public/raw/team-workflows-git-version-control.md +++ b/public/raw/team-workflows-git-version-control.md @@ -1,4 +1,4 @@ -# Team Workflows with Git Version Control +# Team Workflows > How teams collaborate on markdown content using git, sync to shared Convex deployments, and automate production syncs with CI/CD. diff --git a/scripts/export-db-posts.ts b/scripts/export-db-posts.ts new file mode 100644 index 0000000..200fc9d --- /dev/null +++ b/scripts/export-db-posts.ts @@ -0,0 +1,262 @@ +import fs from "fs"; +import path from "path"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; +import dotenv from "dotenv"; + +// Load environment variables +const isProduction = process.env.SYNC_ENV === "production"; + +if (isProduction) { + dotenv.config({ path: ".env.production.local" }); + console.log("Exporting from PRODUCTION deployment...\n"); +} else { + dotenv.config({ path: ".env.local" }); +} +dotenv.config(); + +const BLOG_OUTPUT_DIR = path.join(process.cwd(), "content", "blog"); +const PAGES_OUTPUT_DIR = path.join(process.cwd(), "content", "pages"); + +const convexUrl = process.env.VITE_CONVEX_URL; +if (!convexUrl) { + console.error("Error: VITE_CONVEX_URL not found in environment"); + process.exit(1); +} + +const client = new ConvexHttpClient(convexUrl); + +interface Post { + _id: string; + slug: string; + title: string; + description: string; + content: string; + date: string; + published: boolean; + tags: string[]; + readTime?: string; + image?: string; + showImageAtTop?: boolean; + excerpt?: string; + featured?: boolean; + featuredOrder?: number; + authorName?: string; + authorImage?: string; + layout?: string; + rightSidebar?: boolean; + showFooter?: boolean; + footer?: string; + showSocialFooter?: boolean; + aiChat?: boolean; + blogFeatured?: boolean; + newsletter?: boolean; + contactForm?: boolean; + unlisted?: boolean; + docsSection?: boolean; + docsSectionGroup?: string; + docsSectionOrder?: number; + docsSectionGroupOrder?: number; + docsSectionGroupIcon?: string; + docsLanding?: boolean; + source?: "dashboard" | "sync"; +} + +interface Page { + _id: string; + slug: string; + title: string; + content: string; + published: boolean; + order?: number; + showInNav?: boolean; + excerpt?: string; + image?: string; + showImageAtTop?: boolean; + featured?: boolean; + featuredOrder?: number; + authorName?: string; + authorImage?: string; + layout?: string; + rightSidebar?: boolean; + showFooter?: boolean; + footer?: string; + showSocialFooter?: boolean; + aiChat?: boolean; + contactForm?: boolean; + newsletter?: boolean; + textAlign?: string; + docsSection?: boolean; + docsSectionGroup?: string; + docsSectionOrder?: number; + docsSectionGroupOrder?: number; + docsSectionGroupIcon?: string; + docsLanding?: boolean; + source?: "dashboard" | "sync"; +} + +function generatePostMarkdown(post: Post): string { + const frontmatter: string[] = ["---"]; + frontmatter.push(`title: "${post.title.replace(/"/g, '\\"')}"`); + frontmatter.push(`description: "${post.description.replace(/"/g, '\\"')}"`); + frontmatter.push(`date: "${post.date}"`); + frontmatter.push(`slug: "${post.slug}"`); + frontmatter.push(`published: ${post.published}`); + frontmatter.push(`tags: [${post.tags.map((t) => `"${t}"`).join(", ")}]`); + + // Optional fields + if (post.readTime) frontmatter.push(`readTime: "${post.readTime}"`); + if (post.image) frontmatter.push(`image: "${post.image}"`); + if (post.showImageAtTop !== undefined) + frontmatter.push(`showImageAtTop: ${post.showImageAtTop}`); + if (post.excerpt) + frontmatter.push(`excerpt: "${post.excerpt.replace(/"/g, '\\"')}"`); + if (post.featured !== undefined) frontmatter.push(`featured: ${post.featured}`); + if (post.featuredOrder !== undefined) + frontmatter.push(`featuredOrder: ${post.featuredOrder}`); + if (post.authorName) frontmatter.push(`authorName: "${post.authorName}"`); + if (post.authorImage) frontmatter.push(`authorImage: "${post.authorImage}"`); + if (post.layout) frontmatter.push(`layout: "${post.layout}"`); + if (post.rightSidebar !== undefined) + frontmatter.push(`rightSidebar: ${post.rightSidebar}`); + if (post.showFooter !== undefined) + frontmatter.push(`showFooter: ${post.showFooter}`); + if (post.footer) + frontmatter.push(`footer: "${post.footer.replace(/"/g, '\\"')}"`); + if (post.showSocialFooter !== undefined) + frontmatter.push(`showSocialFooter: ${post.showSocialFooter}`); + if (post.aiChat !== undefined) frontmatter.push(`aiChat: ${post.aiChat}`); + if (post.blogFeatured !== undefined) + frontmatter.push(`blogFeatured: ${post.blogFeatured}`); + if (post.newsletter !== undefined) + frontmatter.push(`newsletter: ${post.newsletter}`); + if (post.contactForm !== undefined) + frontmatter.push(`contactForm: ${post.contactForm}`); + if (post.unlisted !== undefined) frontmatter.push(`unlisted: ${post.unlisted}`); + if (post.docsSection !== undefined) + frontmatter.push(`docsSection: ${post.docsSection}`); + if (post.docsSectionGroup) + frontmatter.push(`docsSectionGroup: "${post.docsSectionGroup}"`); + if (post.docsSectionOrder !== undefined) + frontmatter.push(`docsSectionOrder: ${post.docsSectionOrder}`); + if (post.docsSectionGroupOrder !== undefined) + frontmatter.push(`docsSectionGroupOrder: ${post.docsSectionGroupOrder}`); + if (post.docsSectionGroupIcon) + frontmatter.push(`docsSectionGroupIcon: "${post.docsSectionGroupIcon}"`); + if (post.docsLanding !== undefined) + frontmatter.push(`docsLanding: ${post.docsLanding}`); + + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${post.content}`; +} + +function generatePageMarkdown(page: Page): string { + const frontmatter: string[] = ["---"]; + frontmatter.push(`title: "${page.title.replace(/"/g, '\\"')}"`); + frontmatter.push(`slug: "${page.slug}"`); + frontmatter.push(`published: ${page.published}`); + + // Optional fields + if (page.order !== undefined) frontmatter.push(`order: ${page.order}`); + if (page.showInNav !== undefined) + frontmatter.push(`showInNav: ${page.showInNav}`); + if (page.excerpt) + frontmatter.push(`excerpt: "${page.excerpt.replace(/"/g, '\\"')}"`); + if (page.image) frontmatter.push(`image: "${page.image}"`); + if (page.showImageAtTop !== undefined) + frontmatter.push(`showImageAtTop: ${page.showImageAtTop}`); + if (page.featured !== undefined) frontmatter.push(`featured: ${page.featured}`); + if (page.featuredOrder !== undefined) + frontmatter.push(`featuredOrder: ${page.featuredOrder}`); + if (page.authorName) frontmatter.push(`authorName: "${page.authorName}"`); + if (page.authorImage) frontmatter.push(`authorImage: "${page.authorImage}"`); + if (page.layout) frontmatter.push(`layout: "${page.layout}"`); + if (page.rightSidebar !== undefined) + frontmatter.push(`rightSidebar: ${page.rightSidebar}`); + if (page.showFooter !== undefined) + frontmatter.push(`showFooter: ${page.showFooter}`); + if (page.footer) + frontmatter.push(`footer: "${page.footer.replace(/"/g, '\\"')}"`); + if (page.showSocialFooter !== undefined) + frontmatter.push(`showSocialFooter: ${page.showSocialFooter}`); + if (page.aiChat !== undefined) frontmatter.push(`aiChat: ${page.aiChat}`); + if (page.contactForm !== undefined) + frontmatter.push(`contactForm: ${page.contactForm}`); + if (page.newsletter !== undefined) + frontmatter.push(`newsletter: ${page.newsletter}`); + if (page.textAlign) frontmatter.push(`textAlign: "${page.textAlign}"`); + if (page.docsSection !== undefined) + frontmatter.push(`docsSection: ${page.docsSection}`); + if (page.docsSectionGroup) + frontmatter.push(`docsSectionGroup: "${page.docsSectionGroup}"`); + if (page.docsSectionOrder !== undefined) + frontmatter.push(`docsSectionOrder: ${page.docsSectionOrder}`); + if (page.docsSectionGroupOrder !== undefined) + frontmatter.push(`docsSectionGroupOrder: ${page.docsSectionGroupOrder}`); + if (page.docsSectionGroupIcon) + frontmatter.push(`docsSectionGroupIcon: "${page.docsSectionGroupIcon}"`); + if (page.docsLanding !== undefined) + frontmatter.push(`docsLanding: ${page.docsLanding}`); + + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${page.content}`; +} + +async function main() { + console.log("Exporting dashboard content to markdown files...\n"); + + // Ensure output directories exist + if (!fs.existsSync(BLOG_OUTPUT_DIR)) { + fs.mkdirSync(BLOG_OUTPUT_DIR, { recursive: true }); + } + if (!fs.existsSync(PAGES_OUTPUT_DIR)) { + fs.mkdirSync(PAGES_OUTPUT_DIR, { recursive: true }); + } + + // Get all posts + const posts = (await client.query(api.posts.listAll)) as Post[]; + const dashboardPosts = posts.filter((p) => p.source === "dashboard"); + + console.log(`Found ${dashboardPosts.length} dashboard posts to export\n`); + + let exportedPosts = 0; + for (const post of dashboardPosts) { + const markdown = generatePostMarkdown(post); + const filePath = path.join(BLOG_OUTPUT_DIR, `${post.slug}.md`); + fs.writeFileSync(filePath, markdown, "utf-8"); + console.log(` Exported: ${post.slug}.md`); + exportedPosts++; + } + + // Get all pages + const pages = (await client.query(api.pages.listAll)) as Page[]; + const dashboardPages = pages.filter((p) => p.source === "dashboard"); + + console.log(`\nFound ${dashboardPages.length} dashboard pages to export\n`); + + let exportedPages = 0; + for (const page of dashboardPages) { + const markdown = generatePageMarkdown(page); + const filePath = path.join(PAGES_OUTPUT_DIR, `${page.slug}.md`); + fs.writeFileSync(filePath, markdown, "utf-8"); + console.log(` Exported: ${page.slug}.md`); + exportedPages++; + } + + console.log("\n-------------------------------------------"); + console.log(`Export complete!`); + console.log(` Posts exported: ${exportedPosts}`); + console.log(` Pages exported: ${exportedPages}`); + console.log("-------------------------------------------\n"); + + if (exportedPosts + exportedPages > 0) { + console.log("Next steps:"); + console.log(" 1. Review the exported files in content/blog/ and content/pages/"); + console.log(" 2. Run 'npm run sync' to sync them back (they will keep source: 'sync')"); + console.log(" 3. Delete the dashboard originals if you want to switch to file-based workflow"); + } +} + +main().catch(console.error); diff --git a/scripts/sync-posts.ts b/scripts/sync-posts.ts index 861e412..80ca2cf 100644 --- a/scripts/sync-posts.ts +++ b/scripts/sync-posts.ts @@ -375,6 +375,24 @@ async function syncPosts() { } } + // Generate embeddings for semantic search (if OPENAI_API_KEY is configured) + console.log("\nGenerating embeddings for semantic search..."); + try { + const embeddingResult = await client.action( + api.embeddings.generateMissingEmbeddings, + {} + ); + if (embeddingResult.skipped) { + console.log(" Skipped: OPENAI_API_KEY not configured"); + } else { + console.log(` Posts: ${embeddingResult.postsProcessed} embeddings generated`); + console.log(` Pages: ${embeddingResult.pagesProcessed} embeddings generated`); + } + } catch (error) { + // Non-fatal - continue even if embedding generation fails + console.log(" Warning: Could not generate embeddings:", error); + } + // Generate static raw markdown files in public/raw/ generateRawMarkdownFiles(posts, pages); } diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 273338c..832d5bb 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -1,32 +1,94 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "convex/react"; +import { useQuery, useAction } from "convex/react"; import { api } from "../../convex/_generated/api"; -import { MagnifyingGlass, X, FileText, Article, ArrowRight } from "@phosphor-icons/react"; +import { + MagnifyingGlass, + X, + FileText, + Article, + ArrowRight, + TextAa, + Brain, +} from "@phosphor-icons/react"; interface SearchModalProps { isOpen: boolean; onClose: () => void; } +type SearchMode = "keyword" | "semantic"; + +interface SearchResult { + _id: string; + type: "post" | "page"; + slug: string; + title: string; + description?: string; + snippet: string; + score?: number; + anchor?: string; +} + export default function SearchModal({ isOpen, onClose }: SearchModalProps) { const [searchQuery, setSearchQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); + const [searchMode, setSearchMode] = useState("keyword"); + const [semanticResults, setSemanticResults] = useState(null); + const [isSemanticSearching, setIsSemanticSearching] = useState(false); const inputRef = useRef(null); const navigate = useNavigate(); - // Fetch search results from Convex - const results = useQuery( + // Keyword search (reactive query) + const keywordResults = useQuery( api.search.search, - searchQuery.trim() ? { query: searchQuery } : "skip" + searchMode === "keyword" && searchQuery.trim() ? { query: searchQuery } : "skip" ); + // Semantic search action + const semanticSearchAction = useAction(api.semanticSearch.semanticSearch); + + // Trigger semantic search with debounce + useEffect(() => { + if (searchMode !== "semantic" || !searchQuery.trim()) { + setSemanticResults(null); + setIsSemanticSearching(false); + return; + } + + setIsSemanticSearching(true); + const timeoutId = setTimeout(async () => { + try { + const results = await semanticSearchAction({ query: searchQuery }); + setSemanticResults(results as SearchResult[]); + } catch (error) { + console.error("Semantic search error:", error); + setSemanticResults([]); + } finally { + setIsSemanticSearching(false); + } + }, 300); // 300ms debounce for API calls + + return () => clearTimeout(timeoutId); + }, [searchQuery, searchMode, semanticSearchAction]); + + // Get current results based on mode + const results: SearchResult[] | undefined = + searchMode === "keyword" + ? (keywordResults as SearchResult[] | undefined) + : (semanticResults ?? undefined); + const isLoading = + searchMode === "keyword" + ? keywordResults === undefined && searchQuery.trim() !== "" + : isSemanticSearching; + // Focus input when modal opens useEffect(() => { if (isOpen && inputRef.current) { inputRef.current.focus(); setSearchQuery(""); setSelectedIndex(0); + setSemanticResults(null); } }, [isOpen]); @@ -38,6 +100,21 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { // Handle keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + // Tab toggles between search modes + if (e.key === "Tab") { + e.preventDefault(); + setSearchMode((prev) => (prev === "keyword" ? "semantic" : "keyword")); + return; + } + + // Escape closes modal + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + // Arrow/Enter only work when there are results if (!results || results.length === 0) return; switch (e.key) { @@ -53,25 +130,28 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { e.preventDefault(); if (results[selectedIndex]) { const result = results[selectedIndex]; - // Pass search query as URL param for highlighting on destination page - const url = `/${result.slug}?q=${encodeURIComponent(searchQuery)}`; + // Only pass query param for keyword search (highlighting) + // Semantic search doesn't match exact words + const url = + searchMode === "keyword" + ? `/${result.slug}?q=${encodeURIComponent(searchQuery)}` + : `/${result.slug}`; navigate(url); onClose(); } break; - case "Escape": - e.preventDefault(); - onClose(); - break; } }, - [results, selectedIndex, navigate, onClose] + [results, selectedIndex, navigate, onClose, searchMode, searchQuery] ); // Handle clicking on a result const handleResultClick = (slug: string) => { - // Pass search query as URL param for highlighting on destination page - const url = `/${slug}?q=${encodeURIComponent(searchQuery)}`; + // Only pass query param for keyword search (highlighting) + const url = + searchMode === "keyword" + ? `/${slug}?q=${encodeURIComponent(searchQuery)}` + : `/${slug}`; navigate(url); onClose(); }; @@ -88,6 +168,26 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { return (
+ {/* Search mode toggle */} +
+ + +
+ {/* Search input */}
@@ -97,7 +197,11 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Search posts and pages..." + placeholder={ + searchMode === "keyword" + ? "Search posts and pages..." + : "Describe what you're looking for..." + } className="search-modal-input" autoComplete="off" autoCorrect="off" @@ -113,10 +217,18 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
{searchQuery.trim() === "" ? (
-

Type to search posts and pages

+

+ {searchMode === "keyword" + ? "Type to search posts and pages" + : "Describe what you're looking for"} +

- Navigate + Tab Switch mode + + + + Navigate Select @@ -126,13 +238,20 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
- ) : results === undefined ? ( -
Searching...
- ) : results.length === 0 ? ( + ) : isLoading ? ( +
+ {searchMode === "semantic" ? "Finding similar content..." : "Searching..."} +
+ ) : results && results.length === 0 ? (
No results found for "{searchQuery}" + {searchMode === "semantic" && ( +

+ Try keyword search for exact matches +

+ )}
- ) : ( + ) : results ? (
    {results.map((result, index) => (
  • @@ -152,25 +271,36 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
    {result.title}
    {result.snippet}
-
- {result.type === "post" ? "Post" : "Page"} +
+ + {result.type === "post" ? "Post" : "Page"} + + {searchMode === "semantic" && result.score !== undefined && ( + + {Math.round(result.score * 100)}% + + )}
))} - )} + ) : null}
{/* Footer with keyboard hints */} {results && results.length > 0 && (
- to select + Tab switch mode - to navigate + select + + + + navigate
)} @@ -178,4 +308,3 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
); } - diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index b46f347..e317157 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -10,6 +10,10 @@ import remarkGfm from "remark-gfm"; import remarkBreaks from "remark-breaks"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import ReactQuill from "react-quill"; +import "react-quill/dist/quill.snow.css"; +import TurndownService from "turndown"; +import Showdown from "showdown"; import { ArrowLeft, Article, @@ -34,7 +38,6 @@ import { Clock, Link as LinkIcon, Copy, - ArrowClockwise, Terminal, CheckCircle, Warning, @@ -58,6 +61,7 @@ import { CaretDown, ArrowsOut, ArrowsIn, + FloppyDisk, } from "@phosphor-icons/react"; import siteConfig from "../config/siteConfig"; import AIChatView from "../components/AIChatView"; @@ -264,6 +268,110 @@ function CommandModal({ ); } +// Confirm Delete modal component +interface ConfirmDeleteModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + itemName: string; + itemType: "post" | "page"; + isDeleting: boolean; +} + +function ConfirmDeleteModal({ + isOpen, + onClose, + onConfirm, + title, + itemName, + itemType, + isDeleting, +}: ConfirmDeleteModalProps) { + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && !isDeleting) { + onClose(); + } + }; + + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isDeleting) { + onClose(); + } + }; + if (isOpen) { + document.addEventListener("keydown", handleEsc); + } + return () => document.removeEventListener("keydown", handleEsc); + }, [isOpen, onClose, isDeleting]); + + if (!isOpen) return null; + + return ( +
+
+
+
+ +
+

{title}

+ +
+ +
+

+ Are you sure you want to delete this {itemType}? +

+
+ + {itemName} +
+

+ This action cannot be undone. The {itemType} will be permanently + removed from the database. +

+
+ +
+
+ + +
+
+
+
+ ); +} + // Dashboard sections type DashboardSection = | "posts" @@ -301,6 +409,7 @@ interface ContentItem { authorName?: string; authorImage?: string; order?: number; + source?: "dashboard" | "sync"; } // Frontmatter fields for posts @@ -551,6 +660,15 @@ function DashboardContent() { description?: string; }>({ isOpen: false, title: "", command: "" }); + // Delete confirmation modal state + const [deleteModal, setDeleteModal] = useState<{ + isOpen: boolean; + id: string; + title: string; + type: "post" | "page"; + }>({ isOpen: false, id: "", title: "", type: "post" }); + const [isDeleting, setIsDeleting] = useState(false); + // Sync server state const [syncOutput, setSyncOutput] = useState(""); const [syncRunning, setSyncRunning] = useState(null); // command id or null @@ -563,6 +681,12 @@ function DashboardContent() { const posts = useQuery(api.posts.listAll); const pages = useQuery(api.pages.listAll); + // CMS mutations for CRUD operations + const deletePostMutation = useMutation(api.cms.deletePost); + const deletePageMutation = useMutation(api.cms.deletePage); + const updatePostMutation = useMutation(api.cms.updatePost); + const updatePageMutation = useMutation(api.cms.updatePage); + // Add toast notification const addToast = useCallback((message: string, type: ToastType = "info") => { const id = crypto.randomUUID(); @@ -732,6 +856,123 @@ function DashboardContent() { setActiveSection("page-editor"); }, []); + // Show delete confirmation modal for a post + const handleDeletePost = useCallback( + (id: string, title: string) => { + setDeleteModal({ + isOpen: true, + id, + title, + type: "post", + }); + }, + [], + ); + + // Show delete confirmation modal for a page + const handleDeletePage = useCallback( + (id: string, title: string) => { + setDeleteModal({ + isOpen: true, + id, + title, + type: "page", + }); + }, + [], + ); + + // Close delete modal + const closeDeleteModal = useCallback(() => { + if (!isDeleting) { + setDeleteModal({ isOpen: false, id: "", title: "", type: "post" }); + } + }, [isDeleting]); + + // Confirm and execute deletion + const confirmDelete = useCallback(async () => { + setIsDeleting(true); + try { + if (deleteModal.type === "post") { + await deletePostMutation({ id: deleteModal.id as Id<"posts"> }); + addToast("Post deleted successfully", "success"); + } else { + await deletePageMutation({ id: deleteModal.id as Id<"pages"> }); + addToast("Page deleted successfully", "success"); + } + setDeleteModal({ isOpen: false, id: "", title: "", type: "post" }); + } catch (error) { + addToast( + error instanceof Error ? error.message : `Failed to delete ${deleteModal.type}`, + "error", + ); + } finally { + setIsDeleting(false); + } + }, [deleteModal, deletePostMutation, deletePageMutation, addToast]); + + // Handle saving post changes + const handleSavePost = useCallback( + async (item: ContentItem) => { + try { + await updatePostMutation({ + id: item._id as Id<"posts">, + post: { + title: item.title, + description: item.description, + content: item.content, + date: item.date, + published: item.published, + tags: item.tags, + excerpt: item.excerpt, + image: item.image, + featured: item.featured, + featuredOrder: item.featuredOrder, + authorName: item.authorName, + authorImage: item.authorImage, + }, + }); + addToast("Post saved successfully", "success"); + } catch (error) { + addToast( + error instanceof Error ? error.message : "Failed to save post", + "error", + ); + } + }, + [updatePostMutation, addToast], + ); + + // Handle saving page changes + const handleSavePage = useCallback( + async (item: ContentItem) => { + try { + await updatePageMutation({ + id: item._id as Id<"pages">, + page: { + title: item.title, + content: item.content, + published: item.published, + order: item.order, + excerpt: item.excerpt, + image: item.image, + featured: item.featured, + featuredOrder: item.featuredOrder, + authorName: item.authorName, + authorImage: item.authorImage, + }, + }); + addToast("Page saved successfully", "success"); + } catch (error) { + addToast( + error instanceof Error ? error.message : "Failed to save page", + "error", + ); + } + }, + [updatePageMutation, addToast], + ); + // Generate markdown content from item const generateMarkdown = useCallback( (item: ContentItem, type: "post" | "page"): string => { @@ -899,6 +1140,17 @@ function DashboardContent() { description={commandModal.description} /> + {/* Delete Confirmation Modal */} + + {/* Left Sidebar */}
+ )}
)) @@ -1392,10 +1668,12 @@ function PagesListView({ pages, onEdit, searchQuery, + onDelete, }: { pages: ContentItem[]; onEdit: (page: ContentItem) => void; searchQuery: string; + onDelete: (id: string, title: string) => void; }) { const [filter, setFilter] = useState<"all" | "published" | "draft">("all"); const [itemsPerPage, setItemsPerPage] = useState(15); @@ -1515,6 +1793,12 @@ function PagesListView({ > {page.published ? "Published" : "Draft"} + {page.source === "dashboard" && ( + Dashboard + )} + {(!page.source || page.source === "sync") && ( + Synced + )}
+ )}
)) @@ -1573,6 +1866,7 @@ function EditorView({ onDownload, onCopy, onBack, + onSave, }: { item: ContentItem; type: "post" | "page"; @@ -1582,8 +1876,10 @@ function EditorView({ onDownload: () => void; onCopy: () => void; onBack: () => void; + onSave: (item: ContentItem) => Promise; }) { const [copied, setCopied] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("dashboard-sidebar-width"); return saved ? Number(saved) : 280; @@ -1597,6 +1893,15 @@ function EditorView({ setTimeout(() => setCopied(false), 2000); }; + const handleSave = async () => { + setIsSaving(true); + try { + await onSave(item); + } finally { + setIsSaving(false); + } + }; + const startXRef = useRef(0); const startWidthRef = useRef(0); @@ -1679,6 +1984,19 @@ function EditorView({ Download .md + @@ -2173,12 +2491,18 @@ function WriteSection({ contentType, sidebarCollapsed, setSidebarCollapsed, + addToast, }: { contentType: "post" | "page"; sidebarCollapsed: boolean; setSidebarCollapsed: React.Dispatch>; + addToast: (message: string, type?: ToastType) => void; }) { const [content, setContent] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [editorMode, setEditorMode] = useState<"markdown" | "richtext" | "preview">("markdown"); + const createPostMutation = useMutation(api.cms.createPost); + const createPageMutation = useMutation(api.cms.createPage); const [copied, setCopied] = useState(false); const [copiedField, setCopiedField] = useState(null); const [focusMode, setFocusMode] = useState(() => { @@ -2223,6 +2547,75 @@ function WriteSection({ }); }, []); + // HTML <-> Markdown converters + const turndownService = useMemo(() => { + const service = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + }); + return service; + }, []); + + const showdownConverter = useMemo(() => { + const converter = new Showdown.Converter({ + tables: true, + strikethrough: true, + tasklists: true, + }); + return converter; + }, []); + + // Convert between modes - extract body content for rich text editing + const getBodyContent = useCallback((fullContent: string): string => { + const frontmatterMatch = fullContent.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); + return frontmatterMatch ? frontmatterMatch[1].trim() : fullContent; + }, []); + + const getFrontmatter = useCallback((fullContent: string): string => { + const frontmatterMatch = fullContent.match(/^(---\n[\s\S]*?\n---\n?)/); + return frontmatterMatch ? frontmatterMatch[1] : ""; + }, []); + + // State for rich text HTML content + const [richTextHtml, setRichTextHtml] = useState(""); + + // Handle mode changes with content conversion + const handleModeChange = useCallback( + (newMode: "markdown" | "richtext" | "preview") => { + if (newMode === editorMode) return; + + if (newMode === "richtext" && editorMode === "markdown") { + // Converting from markdown to rich text + const bodyContent = getBodyContent(content); + const html = showdownConverter.makeHtml(bodyContent); + setRichTextHtml(html); + } else if (newMode === "markdown" && editorMode === "richtext") { + // Converting from rich text back to markdown + const markdown = turndownService.turndown(richTextHtml); + const frontmatter = getFrontmatter(content); + setContent(frontmatter + markdown); + } + + setEditorMode(newMode); + }, + [editorMode, content, richTextHtml, getBodyContent, getFrontmatter, showdownConverter, turndownService] + ); + + // Quill modules configuration + const quillModules = useMemo( + () => ({ + toolbar: [ + [{ header: [1, 2, 3, false] }], + ["bold", "italic", "strike"], + ["blockquote", "code-block"], + [{ list: "ordered" }, { list: "bullet" }], + ["link"], + ["clean"], + ], + }), + [] + ); + // Keyboard shortcut: Escape to exit focus mode useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -2362,6 +2755,121 @@ published: false URL.revokeObjectURL(url); }, [content, contentType]); + // Parse frontmatter and save to database + const handleSaveToDb = useCallback(async () => { + setIsSaving(true); + try { + // Parse frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!frontmatterMatch) { + addToast("Content must have valid frontmatter (---)", "error"); + return; + } + + const frontmatterText = frontmatterMatch[1]; + const bodyContent = frontmatterMatch[2].trim(); + + // Parse frontmatter fields + const parseValue = (key: string): string | undefined => { + const match = frontmatterText.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?`, "m")); + return match ? match[1].trim() : undefined; + }; + + const parseBool = (key: string): boolean | undefined => { + const match = frontmatterText.match(new RegExp(`^${key}:\\s*(true|false)`, "m")); + return match ? match[1] === "true" : undefined; + }; + + const parseNumber = (key: string): number | undefined => { + const match = frontmatterText.match(new RegExp(`^${key}:\\s*(\\d+)`, "m")); + return match ? parseInt(match[1], 10) : undefined; + }; + + const parseTags = (): string[] => { + const match = frontmatterText.match(/^tags:\s*\[(.*?)\]/m); + if (match) { + return match[1].split(",").map((t) => t.trim().replace(/["']/g, "")).filter(Boolean); + } + return []; + }; + + const title = parseValue("title"); + const slug = parseValue("slug"); + + if (!title || !slug) { + addToast("Frontmatter must include title and slug", "error"); + return; + } + + if (contentType === "post") { + const description = parseValue("description") || ""; + const date = parseValue("date") || new Date().toISOString().split("T")[0]; + const published = parseBool("published") ?? false; + const tags = parseTags(); + const readTime = parseValue("readTime"); + const image = parseValue("image"); + const excerpt = parseValue("excerpt"); + const featured = parseBool("featured"); + const featuredOrder = parseNumber("featuredOrder"); + const authorName = parseValue("authorName"); + const authorImage = parseValue("authorImage"); + + await createPostMutation({ + post: { + slug, + title, + description, + content: bodyContent, + date, + published, + tags, + readTime, + image, + excerpt, + featured, + featuredOrder, + authorName, + authorImage, + }, + }); + addToast(`Post "${title}" saved to database`, "success"); + } else { + const published = parseBool("published") ?? false; + const order = parseNumber("order"); + const showInNav = parseBool("showInNav"); + const excerpt = parseValue("excerpt"); + const image = parseValue("image"); + const featured = parseBool("featured"); + const featuredOrder = parseNumber("featuredOrder"); + const authorName = parseValue("authorName"); + const authorImage = parseValue("authorImage"); + + await createPageMutation({ + page: { + slug, + title, + content: bodyContent, + published, + order, + showInNav, + excerpt, + image, + featured, + featuredOrder, + authorName, + authorImage, + }, + }); + addToast(`Page "${title}" saved to database`, "success"); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save"; + addToast(message, "error"); + } finally { + setIsSaving(false); + } + }, [content, contentType, createPostMutation, createPageMutation, addToast]); + // Calculate stats const lines = content.split("\n").length; const characters = content.length; @@ -2377,6 +2885,26 @@ published: false
{contentType === "post" ? "Blog Post" : "Page"} +
+ + + +
+