mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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 <noreply@anthropic.com> Status: Ready to commit. All semantic search files are staged. The TypeScript warnings are pre-existing (unused variables) and don't affect the build.
This commit is contained in:
156
.claude/plans/librarian-integration.md
Normal file
156
.claude/plans/librarian-integration.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Project instructions for Claude Code.
|
||||
## Project context
|
||||
|
||||
<!-- Auto-updated by sync:discovery -->
|
||||
<!-- Site: markdown | Posts: 17 | Pages: 4 | Updated: 2026-01-04T17:25:36.682Z -->
|
||||
<!-- Site: markdown | Posts: 17 | Pages: 4 | Updated: 2026-01-05T18:54:36.241Z -->
|
||||
|
||||
Markdown sync framework. Write markdown in `content/`, run sync commands, content appears instantly via Convex real-time database. Built for developers and AI agents.
|
||||
|
||||
|
||||
42
TASK.md
42
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
|
||||
|
||||
75
changelog.md
75
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
444
content/pages/docs-configuration.md
Normal file
444
content/pages/docs-configuration.md
Normal file
@@ -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<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `threshold` | Pixels scrolled before button appears |
|
||||
| `smooth` | `true` for smooth scroll, `false` for jump |
|
||||
|
||||
Uses Phosphor ArrowUp icon and works with all themes.
|
||||
|
||||
### Theme
|
||||
|
||||
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
|
||||
|
||||
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 `` or HTML `<img>` 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.
|
||||
307
content/pages/docs-content.md
Normal file
307
content/pages/docs-content.md
Normal file
@@ -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.
|
||||
400
content/pages/docs-dashboard.md
Normal file
400
content/pages/docs-dashboard.md
Normal file
@@ -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 <url> # 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 <url>` - 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 |
|
||||
106
content/pages/docs-deployment.md
Normal file
106
content/pages/docs-deployment.md
Normal file
@@ -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+)
|
||||
121
content/pages/docs-frontmatter.md
Normal file
121
content/pages/docs-frontmatter.md
Normal file
@@ -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`.
|
||||
232
content/pages/docs-search.md
Normal file
232
content/pages/docs-search.md
Normal file
@@ -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 `<mark>` 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
|
||||
134
content/pages/docs-semantic-search.md
Normal file
134
content/pages/docs-semantic-search.md
Normal file
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
12
convex/_generated/api.d.ts
vendored
12
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
|
||||
412
convex/cms.ts
Normal file
412
convex/cms.ts
Normal file
@@ -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}`;
|
||||
},
|
||||
});
|
||||
161
convex/embeddings.ts
Normal file
161
convex/embeddings.ts
Normal file
@@ -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) };
|
||||
}
|
||||
},
|
||||
});
|
||||
105
convex/embeddingsQueries.ts
Normal file
105
convex/embeddingsQueries.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
145
convex/importAction.ts
Normal file
145
convex/importAction.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
156
convex/semanticSearch.ts
Normal file
156
convex/semanticSearch.ts
Normal file
@@ -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) + "...";
|
||||
}
|
||||
62
convex/semanticSearchQueries.ts
Normal file
62
convex/semanticSearchQueries.ts
Normal file
@@ -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;
|
||||
},
|
||||
});
|
||||
16
files.md
16
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 <slug>). 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:
|
||||
|
||||
368
package-lock.json
generated
368
package-lock.json
generated
@@ -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",
|
||||
|
||||
11
package.json
11
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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-04
|
||||
Date: 2026-01-06
|
||||
---
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
436
public/raw/docs-configuration.md
Normal file
436
public/raw/docs-configuration.md
Normal file
@@ -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<ScrollToTopConfig> = {
|
||||
enabled: true, // Set to false to disable
|
||||
threshold: 300, // Show after scrolling 300px
|
||||
smooth: true, // Smooth scroll animation
|
||||
};
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------ |
|
||||
| `enabled` | `true` to show, `false` to hide |
|
||||
| `threshold` | Pixels scrolled before button appears |
|
||||
| `smooth` | `true` for smooth scroll, `false` for jump |
|
||||
|
||||
Uses Phosphor ArrowUp icon and works with all themes.
|
||||
|
||||
### Theme
|
||||
|
||||
Default: `tan`. Options: `dark`, `light`, `tan`, `cloud`.
|
||||
|
||||
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 `` or HTML `<img>` 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.
|
||||
299
public/raw/docs-content.md
Normal file
299
public/raw/docs-content.md
Normal file
@@ -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.
|
||||
392
public/raw/docs-dashboard.md
Normal file
392
public/raw/docs-dashboard.md
Normal file
@@ -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 <url> # 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 <url>` - 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 |
|
||||
98
public/raw/docs-deployment.md
Normal file
98
public/raw/docs-deployment.md
Normal file
@@ -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+)
|
||||
113
public/raw/docs-frontmatter.md
Normal file
113
public/raw/docs-frontmatter.md
Normal file
@@ -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`.
|
||||
224
public/raw/docs-search.md
Normal file
224
public/raw/docs-search.md
Normal file
@@ -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 `<mark>` 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
|
||||
126
public/raw/docs-semantic-search.md
Normal file
126
public/raw/docs-semantic-search.md
Normal file
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
@@ -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](/)**.
|
||||
@@ -28,3 +28,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.
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-04
|
||||
Date: 2026-01-06
|
||||
---
|
||||
|
||||
# Newsletter Demo Page
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
262
scripts/export-db-posts.ts
Normal file
262
scripts/export-db-posts.ts
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<SearchMode>("keyword");
|
||||
const [semanticResults, setSemanticResults] = useState<SearchResult[] | null>(null);
|
||||
const [isSemanticSearching, setIsSemanticSearching] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="search-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="search-modal">
|
||||
{/* Search mode toggle */}
|
||||
<div className="search-mode-toggle">
|
||||
<button
|
||||
className={`search-mode-btn ${searchMode === "keyword" ? "active" : ""}`}
|
||||
onClick={() => setSearchMode("keyword")}
|
||||
title="Keyword search - matches exact words"
|
||||
>
|
||||
<TextAa size={16} weight="bold" />
|
||||
<span>Keyword</span>
|
||||
</button>
|
||||
<button
|
||||
className={`search-mode-btn ${searchMode === "semantic" ? "active" : ""}`}
|
||||
onClick={() => setSearchMode("semantic")}
|
||||
title="Semantic search - finds similar meaning"
|
||||
>
|
||||
<Brain size={16} weight="bold" />
|
||||
<span>Semantic</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="search-modal-input-wrapper">
|
||||
<MagnifyingGlass size={20} className="search-modal-icon" weight="bold" />
|
||||
@@ -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) {
|
||||
<div className="search-modal-results">
|
||||
{searchQuery.trim() === "" ? (
|
||||
<div className="search-modal-hint">
|
||||
<p>Type to search posts and pages</p>
|
||||
<p>
|
||||
{searchMode === "keyword"
|
||||
? "Type to search posts and pages"
|
||||
: "Describe what you're looking for"}
|
||||
</p>
|
||||
<div className="search-modal-shortcuts">
|
||||
<span className="search-shortcut">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> Navigate
|
||||
<kbd>Tab</kbd> Switch mode
|
||||
</span>
|
||||
<span className="search-shortcut">
|
||||
<kbd>↑</kbd>
|
||||
<kbd>↓</kbd> Navigate
|
||||
</span>
|
||||
<span className="search-shortcut">
|
||||
<kbd>↵</kbd> Select
|
||||
@@ -126,13 +238,20 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : results === undefined ? (
|
||||
<div className="search-modal-loading">Searching...</div>
|
||||
) : results.length === 0 ? (
|
||||
) : isLoading ? (
|
||||
<div className="search-modal-loading">
|
||||
{searchMode === "semantic" ? "Finding similar content..." : "Searching..."}
|
||||
</div>
|
||||
) : results && results.length === 0 ? (
|
||||
<div className="search-modal-empty">
|
||||
No results found for "{searchQuery}"
|
||||
{searchMode === "semantic" && (
|
||||
<p className="search-modal-empty-hint">
|
||||
Try keyword search for exact matches
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : results ? (
|
||||
<ul className="search-results-list">
|
||||
{results.map((result, index) => (
|
||||
<li key={result._id}>
|
||||
@@ -152,25 +271,36 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
<div className="search-result-title">{result.title}</div>
|
||||
<div className="search-result-snippet">{result.snippet}</div>
|
||||
</div>
|
||||
<div className="search-result-type">
|
||||
{result.type === "post" ? "Post" : "Page"}
|
||||
<div className="search-result-meta">
|
||||
<span className="search-result-type">
|
||||
{result.type === "post" ? "Post" : "Page"}
|
||||
</span>
|
||||
{searchMode === "semantic" && result.score !== undefined && (
|
||||
<span className="search-result-score">
|
||||
{Math.round(result.score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight size={16} className="search-result-arrow" weight="bold" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
{results && results.length > 0 && (
|
||||
<div className="search-modal-footer">
|
||||
<span className="search-footer-hint">
|
||||
<kbd>↵</kbd> to select
|
||||
<kbd>Tab</kbd> switch mode
|
||||
</span>
|
||||
<span className="search-footer-hint">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> to navigate
|
||||
<kbd>↵</kbd> select
|
||||
</span>
|
||||
<span className="search-footer-hint">
|
||||
<kbd>↑</kbd>
|
||||
<kbd>↓</kbd> navigate
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -178,4 +308,3 @@ export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="dashboard-modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="dashboard-modal dashboard-modal-delete">
|
||||
<div className="dashboard-modal-header">
|
||||
<div className="dashboard-modal-icon dashboard-modal-icon-warning">
|
||||
<Warning size={24} weight="fill" />
|
||||
</div>
|
||||
<h3 className="dashboard-modal-title">{title}</h3>
|
||||
<button
|
||||
className="dashboard-modal-close"
|
||||
onClick={onClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<X size={18} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-modal-content">
|
||||
<p className="dashboard-modal-message">
|
||||
Are you sure you want to delete this {itemType}?
|
||||
</p>
|
||||
<div className="dashboard-modal-item-name">
|
||||
<FileText size={18} />
|
||||
<span>{itemName}</span>
|
||||
</div>
|
||||
<p className="dashboard-modal-warning-text">
|
||||
This action cannot be undone. The {itemType} will be permanently
|
||||
removed from the database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-modal-footer">
|
||||
<div className="dashboard-modal-actions">
|
||||
<button
|
||||
className="dashboard-modal-btn secondary"
|
||||
onClick={onClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="dashboard-modal-btn danger"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<SpinnerGap size={16} className="animate-spin" />
|
||||
<span>Deleting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash size={16} />
|
||||
<span>Delete {itemType}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string>("");
|
||||
const [syncRunning, setSyncRunning] = useState<string | null>(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 */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={closeDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Confirmation"
|
||||
itemName={deleteModal.title}
|
||||
itemType={deleteModal.type}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<aside
|
||||
className={`dashboard-sidebar-left ${sidebarCollapsed ? "collapsed" : ""}`}
|
||||
@@ -1098,6 +1350,7 @@ function DashboardContent() {
|
||||
posts={filteredPosts}
|
||||
onEdit={handleEditPost}
|
||||
searchQuery={searchQuery}
|
||||
onDelete={handleDeletePost}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1107,6 +1360,7 @@ function DashboardContent() {
|
||||
pages={filteredPages}
|
||||
onEdit={handleEditPage}
|
||||
searchQuery={searchQuery}
|
||||
onDelete={handleDeletePage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1125,6 +1379,9 @@ function DashboardContent() {
|
||||
onBack={() =>
|
||||
setActiveSection(editingType === "post" ? "posts" : "pages")
|
||||
}
|
||||
onSave={
|
||||
editingType === "post" ? handleSavePost : handleSavePage
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1134,6 +1391,7 @@ function DashboardContent() {
|
||||
contentType="post"
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
setSidebarCollapsed={setSidebarCollapsed}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1143,6 +1401,7 @@ function DashboardContent() {
|
||||
contentType="page"
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
setSidebarCollapsed={setSidebarCollapsed}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1172,7 +1431,7 @@ function DashboardContent() {
|
||||
|
||||
{/* Import URL */}
|
||||
{activeSection === "import" && (
|
||||
<ImportURLSection showCommandModal={showCommandModal} />
|
||||
<ImportURLSection addToast={addToast} />
|
||||
)}
|
||||
|
||||
{/* Site Config */}
|
||||
@@ -1214,10 +1473,12 @@ function PostsListView({
|
||||
posts,
|
||||
onEdit,
|
||||
searchQuery,
|
||||
onDelete,
|
||||
}: {
|
||||
posts: ContentItem[];
|
||||
onEdit: (post: ContentItem) => void;
|
||||
searchQuery: string;
|
||||
onDelete: (id: string, title: string) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
|
||||
const [itemsPerPage, setItemsPerPage] = useState(15);
|
||||
@@ -1339,6 +1600,12 @@ function PostsListView({
|
||||
>
|
||||
{post.published ? "Published" : "Draft"}
|
||||
</span>
|
||||
{post.source === "dashboard" && (
|
||||
<span className="source-badge dashboard">Dashboard</span>
|
||||
)}
|
||||
{(!post.source || post.source === "sync") && (
|
||||
<span className="source-badge sync">Synced</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-actions">
|
||||
<button
|
||||
@@ -1356,6 +1623,15 @@ function PostsListView({
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
{post.source === "dashboard" && (
|
||||
<button
|
||||
className="action-btn delete"
|
||||
onClick={() => onDelete(post._id, post.title)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -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"}
|
||||
</span>
|
||||
{page.source === "dashboard" && (
|
||||
<span className="source-badge dashboard">Dashboard</span>
|
||||
)}
|
||||
{(!page.source || page.source === "sync") && (
|
||||
<span className="source-badge sync">Synced</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-actions">
|
||||
<button
|
||||
@@ -1532,6 +1816,15 @@ function PagesListView({
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Link>
|
||||
{page.source === "dashboard" && (
|
||||
<button
|
||||
className="action-btn delete"
|
||||
onClick={() => onDelete(page._id, page.title)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
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 size={16} />
|
||||
<span>Download .md</span>
|
||||
</button>
|
||||
<button
|
||||
className="dashboard-action-btn success"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
title="Save to Database"
|
||||
>
|
||||
{isSaving ? (
|
||||
<SpinnerGap size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FloppyDisk size={16} />
|
||||
)}
|
||||
<span>{isSaving ? "Saving..." : "Save"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2173,12 +2491,18 @@ function WriteSection({
|
||||
contentType,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
addToast,
|
||||
}: {
|
||||
contentType: "post" | "page";
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
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<string | null>(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
|
||||
<div className="dashboard-write-header">
|
||||
<div className="dashboard-write-title">
|
||||
<span>{contentType === "post" ? "Blog Post" : "Page"}</span>
|
||||
<div className="dashboard-editor-mode-toggles">
|
||||
<button
|
||||
className={`dashboard-view-toggle ${editorMode === "markdown" ? "active" : ""}`}
|
||||
onClick={() => handleModeChange("markdown")}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
className={`dashboard-view-toggle ${editorMode === "richtext" ? "active" : ""}`}
|
||||
onClick={() => handleModeChange("richtext")}
|
||||
>
|
||||
Rich Text
|
||||
</button>
|
||||
<button
|
||||
className={`dashboard-view-toggle ${editorMode === "preview" ? "active" : ""}`}
|
||||
onClick={() => handleModeChange("preview")}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-write-actions">
|
||||
<button
|
||||
@@ -2406,6 +2934,19 @@ published: false
|
||||
<Download size={16} />
|
||||
<span>Download .md</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveToDb}
|
||||
disabled={isSaving}
|
||||
className="dashboard-action-btn success"
|
||||
title="Save to Database"
|
||||
>
|
||||
{isSaving ? (
|
||||
<SpinnerGap size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FloppyDisk size={16} />
|
||||
)}
|
||||
<span>{isSaving ? "Saving..." : "Save to DB"}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFocusMode}
|
||||
className={`dashboard-action-btn focus-toggle ${focusMode ? "active" : ""}`}
|
||||
@@ -2423,13 +2964,43 @@ published: false
|
||||
<div className="dashboard-write-container">
|
||||
{/* Main Writing Area */}
|
||||
<div className="dashboard-write-main">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="dashboard-write-textarea"
|
||||
placeholder="Start writing your markdown..."
|
||||
spellCheck={true}
|
||||
/>
|
||||
{editorMode === "markdown" && (
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="dashboard-write-textarea"
|
||||
placeholder="Start writing your markdown..."
|
||||
spellCheck={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editorMode === "richtext" && (
|
||||
<div className="dashboard-quill-container">
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={richTextHtml}
|
||||
onChange={setRichTextHtml}
|
||||
modules={quillModules}
|
||||
placeholder="Start writing..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editorMode === "preview" && (
|
||||
<div className="dashboard-preview">
|
||||
<div className="dashboard-preview-content">
|
||||
<div className="blog-post-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
|
||||
>
|
||||
{getBodyContent(content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dashboard-write-footer">
|
||||
<div className="dashboard-write-stats">
|
||||
<span>{words} words</span>
|
||||
@@ -2439,9 +3010,10 @@ published: false
|
||||
<span>{characters} chars</span>
|
||||
</div>
|
||||
<div className="dashboard-write-hint">
|
||||
Save to{" "}
|
||||
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
|
||||
then <code>npm run sync</code>
|
||||
{editorMode === "richtext"
|
||||
? "Editing body content only (frontmatter preserved)"
|
||||
: <>Save to{" "}<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}then <code>npm run sync</code></>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3424,30 +3996,45 @@ function NewsletterStatsSection() {
|
||||
}
|
||||
|
||||
function ImportURLSection({
|
||||
showCommandModal,
|
||||
addToast,
|
||||
}: {
|
||||
showCommandModal: (
|
||||
title: string,
|
||||
command: string,
|
||||
description?: string,
|
||||
) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [publishImmediately, setPublishImmediately] = useState(false);
|
||||
const [lastImported, setLastImported] = useState<{
|
||||
title: string;
|
||||
slug: string;
|
||||
} | null>(null);
|
||||
const importAction = useAction(api.importAction.importFromUrl);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setLastImported(null);
|
||||
|
||||
// Show the command modal with the import command
|
||||
showCommandModal(
|
||||
"Import URL",
|
||||
`npm run import "${url.trim()}"`,
|
||||
"Copy this command and run it in your terminal to import the article",
|
||||
);
|
||||
try {
|
||||
const result = await importAction({
|
||||
url: url.trim(),
|
||||
published: publishImmediately,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
if (result.success && result.slug && result.title) {
|
||||
setLastImported({ title: result.title, slug: result.slug });
|
||||
addToast(`Imported "${result.title}" successfully`, "success");
|
||||
setUrl("");
|
||||
} else {
|
||||
addToast(result.error || "Failed to import URL", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to import URL";
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -3455,7 +4042,7 @@ function ImportURLSection({
|
||||
<div className="dashboard-import-header">
|
||||
<CloudArrowDown size={32} weight="light" />
|
||||
<h2>Import from URL</h2>
|
||||
<p>Import articles from external URLs using Firecrawl</p>
|
||||
<p>Import articles directly to the database using Firecrawl</p>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-import-form">
|
||||
@@ -3467,8 +4054,21 @@ function ImportURLSection({
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/article"
|
||||
className="dashboard-import-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim() && !isLoading) {
|
||||
handleImport();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="dashboard-import-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={publishImmediately}
|
||||
onChange={(e) => setPublishImmediately(e.target.checked)}
|
||||
/>
|
||||
<span>Publish immediately</span>
|
||||
</label>
|
||||
<button
|
||||
className="dashboard-import-btn"
|
||||
onClick={handleImport}
|
||||
@@ -3476,27 +4076,42 @@ function ImportURLSection({
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<ArrowClockwise size={16} className="spin" />
|
||||
<SpinnerGap size={16} className="animate-spin" />
|
||||
<span>Importing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudArrowDown size={16} />
|
||||
<span>Import</span>
|
||||
<span>Import to Database</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastImported && (
|
||||
<div className="dashboard-import-success">
|
||||
<CheckCircle size={20} weight="fill" />
|
||||
<div>
|
||||
<strong>Successfully imported:</strong> {lastImported.title}
|
||||
<br />
|
||||
<Link to={`/${lastImported.slug}`} className="import-view-link">
|
||||
View post →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dashboard-import-info">
|
||||
<h3>How it works</h3>
|
||||
<ol>
|
||||
<li>Enter the URL of an article you want to import</li>
|
||||
<li>Firecrawl will scrape and convert it to markdown</li>
|
||||
<li>A draft post will be created in content/blog/</li>
|
||||
<li>Review, edit, and sync when ready</li>
|
||||
<li>Firecrawl scrapes and converts it to markdown</li>
|
||||
<li>Post is saved directly to the database</li>
|
||||
<li>Edit and publish from the Posts section</li>
|
||||
</ol>
|
||||
<p className="note">Requires FIRECRAWL_API_KEY in .env.local</p>
|
||||
<p className="note">
|
||||
Requires FIRECRAWL_API_KEY in Convex environment variables
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3431,6 +3431,64 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Search mode toggle (Keyword / Semantic) */
|
||||
.search-mode-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.search-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.search-mode-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-mode-btn.active {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Search result meta (type + score) */
|
||||
.search-result-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-result-score {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
/* Empty state hint for semantic search */
|
||||
.search-modal-empty-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Search input wrapper */
|
||||
.search-modal-input-wrapper {
|
||||
display: flex;
|
||||
@@ -8842,8 +8900,8 @@ body {
|
||||
|
||||
.dashboard-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 100px 100px;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr 110px 170px 110px;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
@@ -8893,6 +8951,8 @@ body {
|
||||
.dashboard-list-row .col-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -8914,6 +8974,27 @@ body {
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-badge.dashboard {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.source-badge.sync {
|
||||
background: rgba(107, 114, 128, 0.15);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dashboard-list-row .col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -9054,6 +9135,36 @@ body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dashboard-action-btn.success {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.dashboard-action-btn.success:hover {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.dashboard-action-btn.success:disabled {
|
||||
background: #86efac;
|
||||
border-color: #86efac;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-editor-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -10148,6 +10259,53 @@ body {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dashboard-import-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-import-checkbox input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-import-success {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.dashboard-import-success svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dashboard-import-success strong {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.import-view-link {
|
||||
color: #22c55e;
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.import-view-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Sync Section */
|
||||
.dashboard-sync-section {
|
||||
max-width: 800px;
|
||||
@@ -10578,11 +10736,111 @@ body {
|
||||
}
|
||||
|
||||
.dashboard-write-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-editor-mode-toggles {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-editor-mode-toggles .dashboard-view-toggle {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dashboard-editor-mode-toggles .dashboard-view-toggle:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Quill Editor Styles */
|
||||
.dashboard-quill-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-quill-container .quill {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-editor {
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-editor.ql-blank::before {
|
||||
color: var(--text-tertiary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar {
|
||||
background: var(--bg-primary);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-stroke {
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-fill {
|
||||
fill: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar button:hover .ql-stroke,
|
||||
.dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke {
|
||||
stroke: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar button:hover .ql-fill,
|
||||
.dashboard-quill-container .ql-toolbar button.ql-active .ql-fill {
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker-label:hover,
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker-label.ql-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker-options {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-container {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.dashboard-write-type-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -11330,6 +11588,71 @@ body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Delete confirmation modal */
|
||||
.dashboard-modal-delete {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.dashboard-modal-icon-warning {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.dashboard-modal-content {
|
||||
padding: 0 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.dashboard-modal-message {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.dashboard-modal-item-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-modal-item-name svg {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-modal-warning-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-modal-btn.danger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-modal-btn.danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.dashboard-modal-btn.danger:disabled {
|
||||
background: #fca5a5;
|
||||
border-color: #fca5a5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Modal theme variations */
|
||||
:root[data-theme="dark"] .dashboard-modal-backdrop {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
|
||||
Reference in New Issue
Block a user