new: npx create-markdown-sync CLI , ui , related post thumbnails features

This commit is contained in:
Wayne Sutton
2026-01-10 23:46:08 -08:00
parent 95cc8a4677
commit 55f4ada61a
52 changed files with 4173 additions and 160 deletions

4
.gitignore vendored
View File

@@ -31,6 +31,10 @@ dist-ssr
# Fork configuration (user-specific) # Fork configuration (user-specific)
fork-config.json fork-config.json
# CLI package build artifacts
packages/*/dist/
packages/*/node_modules/

View File

@@ -1,12 +1,47 @@
# Fork Configuration Guide # Fork Configuration Guide
After forking this repo, update these files with your site information. Choose one of two options: After forking this repo, update these files with your site information. Choose one of three options:
**Important**: Keep your `fork-config.json` file after configuring. The `sync:discovery` commands will use it to update discovery files (`AGENTS.md`, `CLAUDE.md`, `public/llms.txt`) with your configured values. **Important**: Keep your `fork-config.json` file after configuring. The `sync:discovery` commands will use it to update discovery files (`AGENTS.md`, `CLAUDE.md`, `public/llms.txt`) with your configured values.
--- ---
## Option 1: Automated Script (Recommended) ## Option 1: npx CLI (Recommended)
Run a single command to scaffold and configure your project with an interactive wizard:
```bash
npx create-markdown-sync my-site
```
The interactive wizard will:
1. Clone the repository
2. Walk through all configuration options (site name, URL, features, etc.)
3. Install dependencies
4. Set up Convex backend (opens browser for login)
5. Run initial content sync
6. Open your site in the browser
### After setup
```bash
cd my-site
npm run dev # Start dev server at localhost:5173
npm run sync # Sync content changes
```
### CLI Options
```bash
npx create-markdown-sync my-site --force # Overwrite existing directory
npx create-markdown-sync my-site --skip-convex # Skip Convex setup
npx create-markdown-sync my-site --skip-open # Don't open browser after setup
```
---
## Option 2: Automated Script
Run a single command to configure all files automatically. Run a single command to configure all files automatically.
@@ -76,7 +111,7 @@ npm run dev # Test locally
--- ---
## Option 2: Manual Configuration ## Option 3: Manual Configuration
Edit each file individually following the guide below. Edit each file individually following the guide below.

26
TASK.md
View File

@@ -4,10 +4,34 @@
## Current Status ## Current Status
v2.18.1 ready. README cleanup with docs links. v2.19.0 ready. npx create-markdown-sync CLI.
## Completed ## Completed
- [x] npx create-markdown-sync CLI (v2.19.0)
- [x] Created packages/create-markdown-sync/ monorepo package
- [x] Interactive wizard with 13 sections (50+ prompts)
- [x] Clone template from GitHub via giget
- [x] Configure site settings automatically
- [x] Install dependencies and set up Convex
- [x] Disable WorkOS auth by default (empty auth.config.ts)
- [x] Start dev server and open browser
- [x] Clear next steps with docs, deployment, and WorkOS links
- [x] Template fixes for siteConfig.ts embedded quotes
- [x] npm publishable package
- [x] Related posts thumbnail view with toggle (v2.18.2)
- [x] Added thumbnail view as default for related posts section
- [x] Card layout with image on left, title/excerpt/meta on right
- [x] Added view toggle button (same icons as homepage featured section)
- [x] Added RelatedPostsConfig interface to siteConfig.ts
- [x] Added relatedPosts config options: defaultViewMode, showViewToggle
- [x] Added config UI in Dashboard ConfigSection
- [x] Updated getRelatedPosts query to return image, excerpt, authorName, authorImage
- [x] Added localStorage persistence for view mode preference
- [x] Added ~100 lines of CSS for thumbnail card styles
- [x] Mobile responsive design for thumbnail cards
- [x] README.md streamlined with docs links (v2.18.1) - [x] README.md streamlined with docs links (v2.18.1)
- [x] Reduced from 609 lines to 155 lines - [x] Reduced from 609 lines to 155 lines
- [x] Added Documentation section with links to markdown.fast/docs - [x] Added Documentation section with links to markdown.fast/docs

View File

@@ -4,6 +4,52 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [2.19.0] - 2026-01-10
### Added
- `npx create-markdown-sync` CLI for scaffolding new projects
- Interactive wizard with 13 sections covering all configuration options
- Clones template from GitHub via giget
- Configures site settings automatically
- Installs dependencies
- Sets up Convex project (optional WorkOS auth disabled by default)
- Starts dev server and opens browser
- Clear next steps with docs, deployment, and WorkOS setup links
### Technical
- New `packages/create-markdown-sync/` monorepo package
- CLI files: index.ts, wizard.ts, clone.ts, configure.ts, install.ts, convex-setup.ts, utils.ts
- Template fixes for siteConfig.ts embedded quotes
- Empty auth.config.ts when auth not required (prevents WorkOS blocking)
- Added workspaces to root package.json
- Updated .gitignore for packages/*/dist/ and packages/*/node_modules/
## [2.18.2] - 2026-01-10
### Added
- Related posts thumbnail view with toggle
- New thumbnail view shows post image, title, description, author, and date
- Toggle button to switch between thumbnail and list views (same icons as homepage featured)
- View preference saved to localStorage
- Default view mode and toggle visibility configurable via siteConfig.relatedPosts
- Dashboard Config section for related posts settings
### Changed
- Updated getRelatedPosts query to return image, excerpt, authorName, authorImage fields
- Related posts section now has header with title and optional toggle button
### Technical
- Added `RelatedPostsConfig` interface to siteConfig.ts
- Added `relatedPosts` configuration to SiteConfig interface
- Updated convex/posts.ts getRelatedPosts query with additional return fields
- Added related posts thumbnail CSS styles (~100 lines)
- Added relatedPostsDefaultViewMode and relatedPostsShowViewToggle to Dashboard ConfigSection
## [2.18.1] - 2026-01-10 ## [2.18.1] - 2026-01-10
### Changed ### Changed

View File

@@ -1,6 +1,7 @@
--- ---
title: "OpenCode Integration" title: "OpenCode Integration"
slug: "docs-opencode" slug: "docs-opencode"
description: "This framework includes full OpenCode support with agents, commands, skills, and plugins."
date: "2026-01-10" date: "2026-01-10"
published: true published: true
tags: ["opencode", "plugins", "terminal"] tags: ["opencode", "plugins", "terminal"]
@@ -8,7 +9,8 @@ readTime: "4 min read"
order: 2 order: 2
showInNav: false showInNav: false
layout: "sidebar" layout: "sidebar"
featuredOrder: 2 featuredOrder: 6
featured: true
blogFeatured: true blogFeatured: true
rightSidebar: true rightSidebar: true
showImageAtTop: false showImageAtTop: false

View File

@@ -23,9 +23,37 @@ docsSectionOrder: 1
After forking this markdown framework, you need to update configuration files with your site information. This affects your site name, URLs, RSS feeds, social sharing metadata, and AI discovery files. After forking this markdown framework, you need to update configuration files with your site information. This affects your site name, URLs, RSS feeds, social sharing metadata, and AI discovery files.
Previously this meant editing 10+ files manually. Now you have two options. Previously this meant editing 10+ files manually. Now you have three options.
## Option 1: Automated configuration ## Option 1: npx CLI (Recommended)
The fastest way to get started. Run a single command to create a new project:
```bash
npx create-markdown-sync my-site
```
The interactive wizard will:
1. Clone the template repository
2. Walk through all configuration options (site name, URLs, features, etc.)
3. Install dependencies
4. Set up Convex (opens browser for login)
5. Start the dev server and open your browser
After setup, follow the on-screen instructions:
```bash
cd my-site
npx convex dev # Start Convex (required first time)
npm run sync # Sync content (in another terminal)
npm run dev # Start dev server
```
**Resources:**
- Deployment: https://www.markdown.fast/docs-deployment
- WorkOS auth: https://www.markdown.fast/how-to-setup-workos
## Option 2: Automated configuration
Run a single command to configure everything at once. Run a single command to configure everything at once.
@@ -91,7 +119,7 @@ Updating public/.well-known/ai-plugin.json...
Configuration complete! Configuration complete!
``` ```
## Option 2: Manual configuration ## Option 3: Manual configuration
If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It includes: If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It includes:
@@ -342,11 +370,12 @@ If you want to clear the sample content, delete the markdown files in those dire
## Summary ## Summary
Two options after forking: Three options to get started:
1. **Automated**: `cp fork-config.json.example fork-config.json`, edit JSON, run `npm run configure` 1. **npx CLI (Recommended)**: `npx create-markdown-sync my-site` - interactive wizard creates and configures everything
2. **Manual**: Follow `FORK_CONFIG.md` step-by-step or paste the AI prompt into Claude/ChatGPT 2. **Automated**: `cp fork-config.json.example fork-config.json`, edit JSON, run `npm run configure`
3. **Manual**: Follow `FORK_CONFIG.md` step-by-step or paste the AI prompt into Claude/ChatGPT
Both approaches update the same 11 files. The automated option takes about 30 seconds. The manual option gives you more control over each change. The npx CLI is the fastest option for new projects. The automated and manual options work best for existing forks.
Fork it, configure it, ship it. Fork it, configure it, ship it.

View File

@@ -4,7 +4,7 @@ description: "Import external articles as markdown posts using Firecrawl. Get yo
date: "2025-12-26" date: "2025-12-26"
slug: "how-to-use-firecrawl" slug: "how-to-use-firecrawl"
published: true published: true
featured: true featured: false
featuredOrder: 6 featuredOrder: 6
image: /images/firecrwall-blog.png image: /images/firecrwall-blog.png
tags: ["tutorial", "firecrawl", "import"] tags: ["tutorial", "firecrawl", "import"]

View File

@@ -11,6 +11,65 @@ docsSectionOrder: 4
All notable changes to this project. All notable changes to this project.
## v2.19.0
Released January 10, 2026
**npx create-markdown-sync CLI**
Added a CLI tool to scaffold new markdown-sync projects with a single command. Run `npx create-markdown-sync my-site` to clone the template, configure your site through an interactive wizard, install dependencies, and set up Convex.
**Changes:**
- Interactive wizard with 13 sections covering all configuration options
- Clones template from GitHub via giget
- Configures site settings automatically (siteConfig.ts, fork-config.json)
- Installs dependencies with your preferred package manager
- Sets up Convex project with optional WorkOS auth (disabled by default)
- Starts dev server and opens browser
- Clear next steps with links to docs, deployment guide, and WorkOS setup
**Files changed:**
- `packages/create-markdown-sync/` - New monorepo package
- `packages/create-markdown-sync/src/index.ts` - CLI entry point
- `packages/create-markdown-sync/src/wizard.ts` - Interactive prompts
- `packages/create-markdown-sync/src/clone.ts` - Template cloning
- `packages/create-markdown-sync/src/configure.ts` - Site configuration
- `packages/create-markdown-sync/src/install.ts` - Dependency installation
- `packages/create-markdown-sync/src/convex-setup.ts` - Convex initialization
- `packages/create-markdown-sync/src/utils.ts` - Helper utilities
- `package.json` - Added workspaces configuration
- `.gitignore` - Added packages/*/dist/ and packages/*/node_modules/
---
## v2.18.2
Released January 10, 2026
**Related posts thumbnail view with toggle**
Added a new thumbnail view for related posts at the bottom of blog posts. Shows post image, title, description, author avatar, author name, and date. Users can toggle between the new thumbnail view and the existing list view, with their preference saved to localStorage.
**Changes:**
- New thumbnail view with image on left, content on right (like attached screenshot)
- Toggle button using same icons as homepage featured section
- View preference saved to localStorage
- Configuration via siteConfig.relatedPosts (defaultViewMode, showViewToggle)
- Dashboard Config section for related posts settings
**Files changed:**
- `convex/posts.ts` - Updated getRelatedPosts query with image, excerpt, authorName, authorImage
- `src/config/siteConfig.ts` - Added RelatedPostsConfig interface and relatedPosts config
- `src/pages/Post.tsx` - Added thumbnail view, toggle state, and view mode rendering
- `src/pages/Dashboard.tsx` - Added Related Posts config card in ConfigSection
- `src/styles/global.css` - Added thumbnail view CSS styles
---
## v2.18.1 ## v2.18.1
Released January 10, 2026 Released January 10, 2026

View File

@@ -7,9 +7,9 @@ order: -1
textAlign: "left" textAlign: "left"
--- ---
An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**. The open-source markdown publishing framework for developers and AI agents to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)** that's always in sync.
Write markdown, sync from the terminal. **[Fork it](https://github.com/waynesutton/markdown-site)**, customize it, ship it. **[Fork it](https://github.com/waynesutton/markdown-site)** or npm <span class="copy-command">npx create-markdown-sync my-site</span>, customize it, ship it.
<!-- This is a comments <!-- This is a comments
Your content is instantly available to browsers, LLMs, and AI Your content is instantly available to browsers, LLMs, and AI
@@ -34,3 +34,7 @@ agents. -->
**Semantic search** - Find content by meaning, not just keywords. **Semantic search** - Find content by meaning, not just keywords.
**Ask AI** - Chat with your site content. Get answers with sources. **Ask AI** - Chat with your site content. Get answers with sources.
```
```

View File

@@ -773,6 +773,10 @@ export const getRelatedPosts = query({
date: v.string(), date: v.string(),
tags: v.array(v.string()), tags: v.array(v.string()),
readTime: v.optional(v.string()), readTime: v.optional(v.string()),
image: v.optional(v.string()),
excerpt: v.optional(v.string()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
sharedTags: v.number(), sharedTags: v.number(),
}), }),
), ),
@@ -804,6 +808,10 @@ export const getRelatedPosts = query({
date: post.date, date: post.date,
tags: post.tags, tags: post.tags,
readTime: post.readTime, readTime: post.readTime,
image: post.image,
excerpt: post.excerpt,
authorName: post.authorName,
authorImage: post.authorImage,
sharedTags, sharedTags,
}; };
}) })

View File

@@ -35,7 +35,7 @@ A brief description of each file in the codebase.
| File | Description | | File | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, featured section with configurable title via featuredTitle, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration with markdown support, social footer configuration, homepage configuration, AI chat configuration, aiDashboard configuration with multi-model support for text chat and image generation, newsletter configuration with admin and notifications, contact form configuration, weekly digest configuration, stats page configuration with public/private toggle, dashboard configuration with optional WorkOS authentication via requireAuth, image lightbox configuration with enabled toggle, semantic search configuration with enabled toggle and disabled by default to avoid blocking forks without OPENAI_API_KEY, twitter configuration for Twitter Cards meta tags, askAI configuration with enabled toggle, default model, and available models for header Ask AI feature) | | `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, featured section with configurable title via featuredTitle, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration with markdown support, social footer configuration, homepage configuration, AI chat configuration, aiDashboard configuration with multi-model support for text chat and image generation, newsletter configuration with admin and notifications, contact form configuration, weekly digest configuration, stats page configuration with public/private toggle, dashboard configuration with optional WorkOS authentication via requireAuth, image lightbox configuration with enabled toggle, semantic search configuration with enabled toggle and disabled by default to avoid blocking forks without OPENAI_API_KEY, twitter configuration for Twitter Cards meta tags, askAI configuration with enabled toggle, default model, and available models for header Ask AI feature, relatedPosts configuration with defaultViewMode and showViewToggle options) |
### Pages (`src/pages/`) ### Pages (`src/pages/`)
@@ -43,7 +43,7 @@ A brief description of each file in the codebase.
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Home.tsx` | Landing page with featured content and optional post list. Fetches home intro content from `content/pages/home.md` (slug: `home-intro`) for synced markdown intro text. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay. Falls back to siteConfig.bio if home-intro page not found. Home intro content uses blog heading styles (blog-h1 through blog-h6) with clickable anchor links, matching blog post typography. Includes helper functions (generateSlug, getTextContent, HeadingAnchor) for heading ID generation and anchor links. Featured section title configurable via siteConfig.featuredTitle (default: "Get started:"). | | `Home.tsx` | Landing page with featured content and optional post list. Fetches home intro content from `content/pages/home.md` (slug: `home-intro`) for synced markdown intro text. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay. Falls back to siteConfig.bio if home-intro page not found. Home intro content uses blog heading styles (blog-h1 through blog-h6) with clickable anchor links, matching blog post typography. Includes helper functions (generateSlug, getTextContent, HeadingAnchor) for heading ID generation and anchor links. Featured section title configurable via siteConfig.featuredTitle (default: "Get started:"). |
| `Blog.tsx` | Dedicated blog page with featured layout: hero post (first blogFeatured), featured row (remaining blogFeatured in 2 columns with excerpts), and regular posts (3 columns without excerpts). Supports list/card view toggle. Includes back button in navigation | | `Blog.tsx` | Dedicated blog page with featured layout: hero post (first blogFeatured), featured row (remaining blogFeatured in 2 columns with excerpts), and regular posts (3 columns without excerpts). Supports list/card view toggle. Includes back button in navigation |
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, related posts section in footer for blog posts, footer component with markdown support (fetches footer.md content from Convex), and social footer. Supports 3-column layout at 1135px+. Can display image at top when showImageAtTop: true. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking). SEO: Dynamic canonical URL, hreflang tags, og:url consistency, and twitter:site meta tags. DOM order optimized for SEO (article before sidebar, CSS order for visual layout). | | `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, related posts section in footer for blog posts with thumbnail/list view toggle, footer component with markdown support (fetches footer.md content from Convex), and social footer. Supports 3-column layout at 1135px+. Can display image at top when showImageAtTop: true. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking). SEO: Dynamic canonical URL, hreflang tags, og:url consistency, and twitter:site meta tags. DOM order optimized for SEO (article before sidebar, CSS order for visual layout). Related posts view mode persists in localStorage. |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars. Configurable via `siteConfig.statsPage` to enable/disable public access and navigation visibility. Shows disabled message when `enabled: false` (similar to NewsletterAdmin pattern). | | `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars. Configurable via `siteConfig.statsPage` to enable/disable public access and navigation visibility. Shows disabled message when `enabled: false` (similar to NewsletterAdmin pattern). |
| `DocsPage.tsx` | Docs landing page component for `/docs` route. Renders the page/post with `docsLanding: true` in DocsLayout. Fetches landing content via `getDocsLandingPage` and `getDocsLandingPost` queries. Includes Footer component (respects showFooter frontmatter), AI chat support (aiChatEnabled), and fallback to first docs item if no landing page is set. | | `DocsPage.tsx` | Docs landing page component for `/docs` route. Renders the page/post with `docsLanding: true` in DocsLayout. Fetches landing content via `getDocsLandingPage` and `getDocsLandingPost` queries. Includes Footer component (respects showFooter frontmatter), AI chat support (aiChatEnabled), and fallback to first docs item if no landing page is set. |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence | | `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
@@ -118,7 +118,7 @@ A brief description of each file in the codebase.
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, aiGeneratedImages, newsletterSubscribers, newsletterSentPosts, contactMessages, askAISessions, contentVersions, versionControlSettings) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), vector search (by_embedding), and version history (by_content, by_createdAt). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. contentVersions stores snapshots before content updates. versionControlSettings stores the enable/disable toggle. | | `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, aiGeneratedImages, newsletterSubscribers, newsletterSentPosts, contactMessages, askAISessions, contentVersions, versionControlSettings) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), vector search (by_embedding), and version history (by_content, by_createdAt). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. contentVersions stores snapshots before content updates. versionControlSettings stores the enable/disable toggle. |
| `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). Captures versions before updates when version control is enabled. | | `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). Captures versions before updates when version control is enabled. |
| `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. | | `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. | | `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, getRelatedPosts (returns image, excerpt, authorName, authorImage for thumbnail view), and getBlogFeaturedPosts. Includes tag-based queries for tag pages and related posts functionality. |
| `pages.ts` | Queries and mutations for static pages | | `pages.ts` | Queries and mutations for static pages |
| `search.ts` | Full text search queries across posts and pages | | `search.ts` | Full text search queries across posts and pages |
| `semanticSearch.ts` | Vector-based semantic search action using OpenAI embeddings | | `semanticSearch.ts` | Vector-based semantic search action using OpenAI embeddings |
@@ -352,6 +352,28 @@ Files include a metadata header with type (post/page), date, reading time, and t
| `convex.md` | Convex patterns specific to this app (indexes, mutations, queries) | | `convex.md` | Convex patterns specific to this app (indexes, mutations, queries) |
| `sync.md` | How sync commands work and content flow from markdown to database | | `sync.md` | How sync commands work and content flow from markdown to database |
## CLI Package (`packages/create-markdown-sync/`)
NPM CLI package for scaffolding new markdown-sync projects with a single command.
| File | Description |
| ---- | ----------- |
| `package.json` | CLI package config with bin entry point |
| `tsconfig.json` | TypeScript config for CLI |
| `README.md` | NPM package readme |
| `src/index.ts` | Main entry point with CLI argument parsing |
| `src/wizard.ts` | Interactive prompts (13 sections, 50+ prompts) |
| `src/clone.ts` | Repository cloning via giget |
| `src/configure.ts` | Fork config generation and template fixes |
| `src/install.ts` | Dependency installation and dev server |
| `src/convex-setup.ts` | Convex project initialization |
| `src/utils.ts` | Validation helpers, logging, package manager detection |
**Usage:**
```bash
npx create-markdown-sync my-site
```
## Cursor Rules (`.cursor/rules/`) ## Cursor Rules (`.cursor/rules/`)
| File | Description | | File | Description |

842
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,9 @@
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"workspaces": [
"packages/*"
],
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:convex": "convex dev", "dev:convex": "convex dev",

View File

@@ -0,0 +1,66 @@
# create-markdown-sync
Create a markdown-sync site with a single command.
## Quick Start
```bash
npx create-markdown-sync my-site
```
This interactive CLI will:
1. Clone the markdown-sync framework
2. Walk through configuration (site name, URL, features, etc.)
3. Install dependencies
4. Set up Convex backend
5. Run initial content sync
6. Open your site in the browser
## Usage
```bash
# Create a new project
npx create-markdown-sync my-blog
# With specific package manager
npx create-markdown-sync my-blog --pm yarn
# Skip Convex setup (configure later)
npx create-markdown-sync my-blog --skip-convex
```
## What You Get
A fully configured markdown-sync site with:
- Real-time content sync via Convex
- Markdown-based blog posts and pages
- Full-text and semantic search
- RSS feeds and sitemap
- AI integrations (Claude, GPT-4, Gemini)
- Newsletter subscriptions (via AgentMail)
- MCP server for AI tool integration
- Dashboard for content management
- Deploy-ready for Netlify
## Requirements
- Node.js 18 or higher
- npm, yarn, pnpm, or bun
## After Setup
```bash
cd my-site
npm run dev # Start dev server at localhost:5173
npm run sync # Sync content changes to Convex
```
## Documentation
Full documentation at [markdown.fast/docs](https://www.markdown.fast/docs)
## License
MIT

View File

@@ -0,0 +1,54 @@
{
"name": "create-markdown-sync",
"version": "0.1.0",
"description": "Create a markdown-sync site with a single command",
"main": "dist/index.js",
"bin": {
"create-markdown-sync": "dist/index.js"
},
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js"
},
"keywords": [
"markdown",
"blog",
"convex",
"cli",
"scaffolding",
"create",
"static-site",
"cms"
],
"author": "Wayne Sutton",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/waynesutton/markdown-site"
},
"bugs": {
"url": "https://github.com/waynesutton/markdown-site/issues"
},
"homepage": "https://github.com/waynesutton/markdown-site#readme",
"engines": {
"node": ">=18"
},
"dependencies": {
"execa": "^8.0.1",
"giget": "^1.2.3",
"kleur": "^4.1.5",
"open": "^10.1.0",
"ora": "^8.0.1",
"prompts": "^2.4.2"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/prompts": "^2.4.9",
"typescript": "^5.2.2"
}
}

View File

@@ -0,0 +1,76 @@
import { downloadTemplate } from 'giget';
import { existsSync } from 'fs';
import { resolve } from 'path';
import ora from 'ora';
import { log } from './utils.js';
const TEMPLATE_REPO = 'github:waynesutton/markdown-site';
export interface CloneOptions {
projectName: string;
cwd?: string;
force?: boolean;
}
export async function cloneTemplate(options: CloneOptions): Promise<string> {
const { projectName, cwd = process.cwd(), force = false } = options;
const targetDir = resolve(cwd, projectName);
// Check if directory already exists
if (existsSync(targetDir) && !force) {
throw new Error(
`Directory "${projectName}" already exists. Use --force to overwrite.`
);
}
const spinner = ora('Cloning markdown-sync template...').start();
try {
await downloadTemplate(TEMPLATE_REPO, {
dir: targetDir,
force,
preferOffline: false,
});
spinner.succeed('Template cloned successfully');
return targetDir;
} catch (error) {
spinner.fail('Failed to clone template');
if (error instanceof Error) {
if (error.message.includes('404')) {
log.error('Template repository not found. Please check the repository URL.');
} else if (error.message.includes('network')) {
log.error('Network error. Please check your internet connection.');
} else {
log.error(error.message);
}
}
throw error;
}
}
// Remove files that shouldn't be in the cloned project
export async function cleanupClonedFiles(projectDir: string): Promise<void> {
const { rm } = await import('fs/promises');
const { join } = await import('path');
const filesToRemove = [
// Remove CLI package from cloned repo (it's installed via npm)
'packages',
// Remove any existing fork-config.json (will be regenerated)
'fork-config.json',
// Remove git history for fresh start
'.git',
];
for (const file of filesToRemove) {
const filePath = join(projectDir, file);
try {
await rm(filePath, { recursive: true, force: true });
} catch {
// Ignore if file doesn't exist
}
}
}

View File

@@ -0,0 +1,302 @@
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { execa } from 'execa';
import ora from 'ora';
import type { WizardAnswers } from './wizard.js';
import { log, extractDomain } from './utils.js';
// Fix template siteConfig.ts to have clean values before running configure-fork.ts
// This is needed because the template may have values with embedded quotes that break regex
function fixTemplateSiteConfig(projectDir: string): void {
const siteConfigPath = join(projectDir, 'src/config/siteConfig.ts');
let content = readFileSync(siteConfigPath, 'utf-8');
// Fix name field - replace any problematic value with a clean placeholder
// Match: name: 'anything' or name: "anything" (properly handling escaped quotes)
content = content.replace(
/name: ['"].*?["'](?:.*?['"])?,/,
'name: "markdown-sync",'
);
// Also try a more aggressive fix for malformed values
content = content.replace(
/name: '.*?framework',/,
'name: "markdown-sync",'
);
writeFileSync(siteConfigPath, content, 'utf-8');
}
// Create empty auth config when user doesn't need authentication
// This prevents WorkOS env var errors from blocking Convex setup
function disableAuthConfig(projectDir: string): void {
const authConfigPath = join(projectDir, 'convex/auth.config.ts');
// Replace with empty auth config (no providers)
const emptyAuthConfig = `// Auth configuration (WorkOS disabled)
// To enable WorkOS authentication, see: https://docs.convex.dev/auth/authkit/
//
// 1. Create a WorkOS account at https://workos.com
// 2. Set WORKOS_CLIENT_ID in your Convex dashboard environment variables
// 3. Replace this file with the WorkOS auth config
const authConfig = {
providers: [],
};
export default authConfig;
`;
writeFileSync(authConfigPath, emptyAuthConfig, 'utf-8');
}
// Convert wizard answers to fork-config.json structure
function buildForkConfig(answers: WizardAnswers): Record<string, unknown> {
return {
siteName: answers.siteName,
siteTitle: answers.siteTitle,
siteDescription: answers.siteDescription,
siteUrl: answers.siteUrl,
siteDomain: extractDomain(answers.siteUrl),
githubUsername: answers.githubUsername,
githubRepo: answers.githubRepo,
contactEmail: answers.contactEmail,
creator: {
name: answers.creatorName,
twitter: answers.twitter,
linkedin: answers.linkedin,
github: answers.github,
},
bio: answers.bio,
gitHubRepoConfig: {
owner: answers.githubUsername,
repo: answers.githubRepo,
branch: answers.branch,
contentPath: answers.contentPath,
},
logoGallery: {
enabled: answers.logoGalleryEnabled,
title: 'Built with',
scrolling: answers.logoGalleryScrolling,
maxItems: 4,
},
gitHubContributions: {
enabled: answers.githubContributionsEnabled,
username: answers.githubContributionsUsername,
showYearNavigation: true,
linkToProfile: true,
title: 'GitHub Activity',
},
visitorMap: {
enabled: answers.visitorMapEnabled,
title: 'Live Visitors',
},
blogPage: {
enabled: answers.blogPageEnabled,
showInNav: true,
title: answers.blogPageTitle,
description: 'All posts from the blog, sorted by date.',
order: 2,
},
postsDisplay: {
showOnHome: answers.showPostsOnHome,
showOnBlogPage: true,
homePostsLimit: answers.homePostsLimit || undefined,
homePostsReadMore: answers.homePostsReadMoreEnabled
? {
enabled: true,
text: answers.homePostsReadMoreText,
link: answers.homePostsReadMoreLink,
}
: {
enabled: false,
text: 'Read more blog posts',
link: '/blog',
},
},
featuredViewMode: answers.featuredViewMode,
showViewToggle: answers.showViewToggle,
theme: answers.theme,
fontFamily: answers.fontFamily,
homepage: {
type: answers.homepageType,
slug: null,
originalHomeRoute: '/home',
},
rightSidebar: {
enabled: true,
minWidth: 1135,
},
footer: {
enabled: answers.footerEnabled,
showOnHomepage: true,
showOnPosts: true,
showOnPages: true,
showOnBlogPage: true,
defaultContent: answers.footerDefaultContent,
},
socialFooter: {
enabled: answers.socialFooterEnabled,
showOnHomepage: true,
showOnPosts: true,
showOnPages: true,
showOnBlogPage: true,
showInHeader: answers.socialFooterShowInHeader,
socialLinks: [
{
platform: 'github',
url: `https://github.com/${answers.githubUsername}/${answers.githubRepo}`,
},
{
platform: 'twitter',
url: answers.twitter,
},
{
platform: 'linkedin',
url: answers.linkedin,
},
],
copyright: {
siteName: answers.copyrightSiteName,
showYear: true,
},
},
aiChat: {
enabledOnWritePage: false,
enabledOnContent: false,
},
aiDashboard: {
enableImageGeneration: true,
defaultTextModel: 'claude-sonnet-4-20250514',
textModels: [
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' },
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', provider: 'google' },
],
imageModels: [
{ id: 'gemini-2.0-flash-exp-image-generation', name: 'Nano Banana', provider: 'google' },
{ id: 'imagen-3.0-generate-002', name: 'Nano Banana Pro', provider: 'google' },
],
},
newsletter: {
enabled: answers.newsletterEnabled,
agentmail: {
inbox: 'newsletter@mail.agentmail.to',
},
signup: {
home: {
enabled: answers.newsletterHomeEnabled,
position: 'above-footer',
title: 'Stay Updated',
description: 'Get new posts delivered to your inbox.',
},
blogPage: {
enabled: answers.newsletterEnabled,
position: 'above-footer',
title: 'Subscribe',
description: 'Get notified when new posts are published.',
},
posts: {
enabled: answers.newsletterEnabled,
position: 'below-content',
title: 'Enjoyed this post?',
description: 'Subscribe for more updates.',
},
},
},
contactForm: {
enabled: answers.contactFormEnabled,
title: answers.contactFormTitle,
description: 'Send us a message and we\'ll get back to you.',
},
newsletterAdmin: {
enabled: false,
showInNav: false,
},
newsletterNotifications: {
enabled: false,
newSubscriberAlert: false,
weeklyStatsSummary: false,
},
weeklyDigest: {
enabled: false,
dayOfWeek: 0,
subject: 'Weekly Digest',
},
statsPage: {
enabled: answers.statsPageEnabled,
showInNav: answers.statsPageEnabled,
},
mcpServer: {
enabled: answers.mcpServerEnabled,
endpoint: '/mcp',
publicRateLimit: 50,
authenticatedRateLimit: 1000,
requireAuth: false,
},
imageLightbox: {
enabled: answers.imageLightboxEnabled,
},
dashboard: {
enabled: answers.dashboardEnabled,
requireAuth: answers.dashboardRequireAuth,
},
semanticSearch: {
enabled: answers.semanticSearchEnabled,
},
twitter: {
site: answers.twitterSite,
creator: answers.twitterCreator,
},
askAI: {
enabled: answers.askAIEnabled,
defaultModel: 'claude-sonnet-4-20250514',
models: [
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' },
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
],
},
};
}
export async function configureProject(
projectDir: string,
answers: WizardAnswers
): Promise<void> {
const spinner = ora('Generating configuration...').start();
try {
// 1. Fix template siteConfig.ts to have clean values (fixes embedded quote issues)
fixTemplateSiteConfig(projectDir);
// 2. Disable auth config if user doesn't need authentication
// This prevents WorkOS env var errors from blocking Convex setup
if (!answers.dashboardRequireAuth) {
disableAuthConfig(projectDir);
}
// 3. Build fork-config.json content
const forkConfig = buildForkConfig(answers);
// 4. Write fork-config.json
const configPath = join(projectDir, 'fork-config.json');
writeFileSync(configPath, JSON.stringify(forkConfig, null, 2));
spinner.text = 'Running configuration script...';
// 5. Run existing configure-fork.ts script with --silent flag
await execa('npx', ['tsx', 'scripts/configure-fork.ts', '--silent'], {
cwd: projectDir,
stdio: 'pipe', // Capture output
});
spinner.succeed('Site configured successfully');
} catch (error) {
spinner.fail('Configuration failed');
if (error instanceof Error) {
log.error(error.message);
}
throw error;
}
}

View File

@@ -0,0 +1,107 @@
import { execa } from 'execa';
import ora from 'ora';
import { log } from './utils.js';
export async function setupConvex(
projectDir: string,
projectName: string
): Promise<boolean> {
const spinner = ora('Setting up Convex...').start();
try {
// Check if user is logged in to Convex
spinner.text = 'Checking Convex authentication...';
const { stdout: whoami } = await execa('npx', ['convex', 'whoami'], {
cwd: projectDir,
reject: false,
});
// If not logged in, prompt for login
if (!whoami || whoami.includes('Not logged in')) {
spinner.text = 'Opening browser for Convex login...';
await execa('npx', ['convex', 'login'], {
cwd: projectDir,
stdio: 'inherit', // Show login flow
});
}
// Initialize Convex project
// Stop spinner to allow interactive prompts from Convex CLI
spinner.stop();
console.log('');
log.step('Initializing Convex project...');
console.log('');
// Use convex dev --once to set up project without running in watch mode
// This creates .env.local with CONVEX_URL
// stdio: 'inherit' allows user to respond to Convex prompts (new project vs existing)
await execa('npx', ['convex', 'dev', '--once'], {
cwd: projectDir,
stdio: 'inherit',
env: {
...process.env,
// Set project name if needed
CONVEX_PROJECT_NAME: projectName,
},
});
console.log('');
log.success('Convex project initialized');
return true;
} catch (error) {
// Spinner may already be stopped, so use log.error instead
spinner.stop();
// Check if .env.local was created despite the error
// This happens when Convex project is created but auth config has missing env vars
const fs = await import('fs');
const path = await import('path');
const envLocalPath = path.join(projectDir, '.env.local');
if (fs.existsSync(envLocalPath)) {
const envContent = fs.readFileSync(envLocalPath, 'utf-8');
if (envContent.includes('CONVEX_DEPLOYMENT') || envContent.includes('VITE_CONVEX_URL')) {
// Convex was set up, just auth config had issues (missing WORKOS_CLIENT_ID etc)
console.log('');
log.success('Convex project created');
log.warn('Auth config has missing environment variables (optional)');
log.info('Set them up later in the Convex dashboard if you want authentication');
return true;
}
}
log.error('Convex setup failed');
if (error instanceof Error) {
if (error.message.includes('ENOENT')) {
log.error('Convex CLI not found. Install with: npm install -g convex');
} else {
log.error(error.message);
}
}
log.warn('You can set up Convex later with: npx convex dev');
return false;
}
}
export async function deployConvexFunctions(projectDir: string): Promise<void> {
const spinner = ora('Deploying Convex functions...').start();
try {
await execa('npx', ['convex', 'deploy'], {
cwd: projectDir,
stdio: 'pipe',
});
spinner.succeed('Convex functions deployed');
} catch (error) {
spinner.fail('Convex deployment failed');
if (error instanceof Error) {
log.warn('You can deploy later with: npx convex deploy');
}
}
}

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env node
import { printBanner, printSuccess, log } from './utils.js';
import { runWizard } from './wizard.js';
import { cloneTemplate, cleanupClonedFiles } from './clone.js';
import { configureProject } from './configure.js';
import { installDependencies, runInitialSync, startDevServer } from './install.js';
import { setupConvex } from './convex-setup.js';
const VERSION = '0.1.0';
async function main(): Promise<void> {
// Parse command line arguments
const args = process.argv.slice(2);
const projectName = args.find(arg => !arg.startsWith('-'));
const force = args.includes('--force') || args.includes('-f');
const skipConvex = args.includes('--skip-convex');
const skipOpen = args.includes('--skip-open');
const showHelp = args.includes('--help') || args.includes('-h');
const showVersion = args.includes('--version') || args.includes('-v');
// Handle --version
if (showVersion) {
console.log(`create-markdown-sync v${VERSION}`);
process.exit(0);
}
// Handle --help
if (showHelp) {
console.log(`
create-markdown-sync v${VERSION}
Create a markdown-sync site with a single command.
Usage:
npx create-markdown-sync [project-name] [options]
Options:
-f, --force Overwrite existing directory
--skip-convex Skip Convex setup
--skip-open Don't open browser after setup
-h, --help Show this help message
-v, --version Show version number
Examples:
npx create-markdown-sync my-blog
npx create-markdown-sync my-site --force
npx create-markdown-sync my-app --skip-convex
Documentation: https://www.markdown.fast/docs
`);
process.exit(0);
}
// Print welcome banner
printBanner(VERSION);
try {
// Run interactive wizard
const answers = await runWizard(projectName);
if (!answers) {
log.error('Setup cancelled');
process.exit(1);
}
console.log('');
log.step(`Creating project in ./${answers.projectName}`);
console.log('');
// Step 1: Clone template
const projectDir = await cloneTemplate({
projectName: answers.projectName,
force,
});
// Step 2: Clean up cloned files
await cleanupClonedFiles(projectDir);
// Step 3: Configure project
await configureProject(projectDir, answers);
// Step 4: Install dependencies
await installDependencies(projectDir, answers.packageManager);
// Step 5: Setup Convex (if not skipped)
let convexSetup = false;
if (answers.initConvex && !skipConvex) {
convexSetup = await setupConvex(projectDir, answers.convexProjectName);
}
// Step 6: Run initial sync (only if Convex is set up)
if (convexSetup) {
await runInitialSync(projectDir, answers.packageManager);
}
// Step 7: Start dev server and open browser
if (!skipOpen) {
await startDevServer(projectDir, answers.packageManager);
}
// Print success message
printSuccess(answers.projectName);
} catch (error) {
console.log('');
if (error instanceof Error) {
log.error(error.message);
} else {
log.error('An unexpected error occurred');
}
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,92 @@
import { execa } from 'execa';
import ora from 'ora';
import open from 'open';
import { log, sleep, getInstallCommand, getRunCommand } from './utils.js';
export async function installDependencies(
projectDir: string,
packageManager: string
): Promise<void> {
const spinner = ora('Installing dependencies...').start();
try {
const [cmd, ...args] = getInstallCommand(packageManager);
await execa(cmd, args, {
cwd: projectDir,
stdio: 'pipe', // Suppress output
});
spinner.succeed('Dependencies installed');
} catch (error) {
spinner.fail('Failed to install dependencies');
if (error instanceof Error) {
log.error(error.message);
}
throw error;
}
}
export async function runInitialSync(
projectDir: string,
packageManager: string
): Promise<void> {
const spinner = ora('Running initial content sync...').start();
try {
const [cmd, ...args] = getRunCommand(packageManager, 'sync');
await execa(cmd, args, {
cwd: projectDir,
stdio: 'pipe',
});
spinner.succeed('Initial sync completed');
} catch {
// Sync often fails on first run because Convex functions need to be deployed first
// This is expected and not an error - just inform the user what to do
spinner.stop();
log.warn('Sync requires Convex functions to be deployed first');
log.info('After setup completes, run: npx convex dev');
log.info('Then in another terminal: npm run sync');
}
}
export async function startDevServer(
projectDir: string,
packageManager: string
): Promise<void> {
const spinner = ora('Starting development server...').start();
try {
const [cmd, ...args] = getRunCommand(packageManager, 'dev');
// Start dev server in detached mode (won't block)
const devProcess = execa(cmd, args, {
cwd: projectDir,
detached: true,
stdio: 'ignore',
});
// Unref to allow parent process to exit
devProcess.unref();
spinner.succeed('Development server starting');
// Wait for server to be ready
log.info('Waiting for server to start...');
await sleep(3000);
// Open browser
await open('http://localhost:5173');
log.success('Opened browser at http://localhost:5173');
} catch (error) {
spinner.fail('Failed to start development server');
if (error instanceof Error) {
log.warn('You can start the server manually with: npm run dev');
}
}
}

View File

@@ -0,0 +1,133 @@
import kleur from 'kleur';
// Sleep helper for async delays
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Validate email format
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Validate URL format
export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
// Extract domain from URL
export function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return '';
}
}
// Validate GitHub username (alphanumeric and hyphens)
export function isValidGitHubUsername(username: string): boolean {
const usernameRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
return usernameRegex.test(username);
}
// Extract GitHub username from URL
export function extractGitHubUsername(url: string): string {
const match = url.match(/github\.com\/([^\/]+)/);
return match ? match[1] : '';
}
// Extract Twitter handle from URL
export function extractTwitterHandle(url: string): string {
const match = url.match(/(?:twitter\.com|x\.com)\/([^\/]+)/);
return match ? `@${match[1]}` : '';
}
// Logging helpers with colors
export const log = {
info: (msg: string) => console.log(kleur.blue('i') + ' ' + msg),
success: (msg: string) => console.log(kleur.green('✓') + ' ' + msg),
warn: (msg: string) => console.log(kleur.yellow('!') + ' ' + msg),
error: (msg: string) => console.log(kleur.red('✗') + ' ' + msg),
step: (msg: string) => console.log(kleur.cyan('→') + ' ' + msg),
dim: (msg: string) => console.log(kleur.dim(msg)),
};
// Print a section header
export function printSection(title: string, current: number, total: number): void {
console.log('');
console.log(kleur.bold().cyan(`[${current}/${total}] ${title}`));
console.log(kleur.dim('─'.repeat(40)));
}
// Print welcome banner
export function printBanner(version: string): void {
console.log('');
console.log(kleur.bold().cyan('create-markdown-sync') + kleur.dim(` v${version}`));
console.log('');
}
// Print success message with next steps
export function printSuccess(projectName: string): void {
console.log('');
console.log(kleur.bold().green('Success!') + ' Your site is ready.');
console.log('');
console.log('Next steps:');
console.log(kleur.cyan(` cd ${projectName}`));
console.log(kleur.cyan(' npx convex dev') + kleur.dim(' # Start Convex (required first time)'));
console.log(kleur.cyan(' npm run sync') + kleur.dim(' # Sync content (in another terminal)'));
console.log(kleur.cyan(' npm run dev') + kleur.dim(' # Start dev server'));
console.log('');
console.log('Resources:');
console.log(kleur.dim(' Docs: https://www.markdown.fast/docs'));
console.log(kleur.dim(' Deployment: https://www.markdown.fast/docs-deployment'));
console.log(kleur.dim(' WorkOS: https://www.markdown.fast/how-to-setup-workos'));
console.log('');
console.log(kleur.dim('To remove and start over:'));
console.log(kleur.dim(` rm -rf ${projectName}`));
console.log(kleur.dim(` npx create-markdown-sync ${projectName}`));
console.log('');
}
// Detect available package manager
export function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' {
const userAgent = process.env.npm_config_user_agent || '';
if (userAgent.includes('yarn')) return 'yarn';
if (userAgent.includes('pnpm')) return 'pnpm';
if (userAgent.includes('bun')) return 'bun';
return 'npm';
}
// Get install command for package manager
export function getInstallCommand(pm: string): string[] {
switch (pm) {
case 'yarn':
return ['yarn'];
case 'pnpm':
return ['pnpm', 'install'];
case 'bun':
return ['bun', 'install'];
default:
return ['npm', 'install'];
}
}
// Get run command for package manager
export function getRunCommand(pm: string, script: string): string[] {
switch (pm) {
case 'yarn':
return ['yarn', script];
case 'pnpm':
return ['pnpm', script];
case 'bun':
return ['bun', 'run', script];
default:
return ['npm', 'run', script];
}
}

View File

@@ -0,0 +1,604 @@
import prompts from 'prompts';
import {
printSection,
isValidEmail,
isValidUrl,
extractDomain,
extractGitHubUsername,
extractTwitterHandle,
detectPackageManager,
} from './utils.js';
// Wizard answers interface matching fork-config.json structure
export interface WizardAnswers {
// Section 1: Project Setup
projectName: string;
packageManager: 'npm' | 'yarn' | 'pnpm' | 'bun';
// Section 2: Site Identity
siteName: string;
siteTitle: string;
siteDescription: string;
siteUrl: string;
contactEmail: string;
// Section 3: Creator Info
creatorName: string;
twitter: string;
linkedin: string;
github: string;
// Section 4: GitHub Repository
githubUsername: string;
githubRepo: string;
branch: string;
contentPath: string;
// Section 5: Appearance
theme: 'dark' | 'light' | 'tan' | 'cloud';
fontFamily: 'serif' | 'sans' | 'monospace';
bio: string;
// Section 6: Homepage & Featured
homepageType: 'default' | 'page' | 'post';
featuredViewMode: 'cards' | 'list';
featuredTitle: string;
showViewToggle: boolean;
// Section 7: Blog & Posts
blogPageEnabled: boolean;
blogPageTitle: string;
showPostsOnHome: boolean;
homePostsLimit: number;
homePostsReadMoreEnabled: boolean;
homePostsReadMoreText: string;
homePostsReadMoreLink: string;
// Section 8: Features
logoGalleryEnabled: boolean;
logoGalleryScrolling: boolean;
githubContributionsEnabled: boolean;
githubContributionsUsername: string;
visitorMapEnabled: boolean;
statsPageEnabled: boolean;
imageLightboxEnabled: boolean;
// Section 9: Footer & Social
footerEnabled: boolean;
footerDefaultContent: string;
socialFooterEnabled: boolean;
socialFooterShowInHeader: boolean;
copyrightSiteName: string;
// Section 10: Newsletter & Contact
newsletterEnabled: boolean;
newsletterHomeEnabled: boolean;
contactFormEnabled: boolean;
contactFormTitle: string;
// Section 11: Advanced Features
mcpServerEnabled: boolean;
semanticSearchEnabled: boolean;
askAIEnabled: boolean;
dashboardEnabled: boolean;
dashboardRequireAuth: boolean;
// Section 12: Twitter/X Config
twitterSite: string;
twitterCreator: string;
// Section 13: Convex Setup
initConvex: boolean;
convexProjectName: string;
}
const TOTAL_SECTIONS = 13;
export async function runWizard(initialProjectName?: string): Promise<WizardAnswers | null> {
const answers: Partial<WizardAnswers> = {};
// Handle cancellation
const onCancel = () => {
console.log('\nSetup cancelled.');
process.exit(0);
};
// SECTION 1: Project Setup
printSection('Project Setup', 1, TOTAL_SECTIONS);
const section1 = await prompts([
{
type: 'text',
name: 'projectName',
message: 'Project name (directory)',
initial: initialProjectName || 'my-markdown-site',
validate: (value: string) => {
if (!value.trim()) return 'Project name is required';
if (!/^[a-zA-Z0-9-_]+$/.test(value)) return 'Only letters, numbers, hyphens, and underscores';
return true;
},
},
{
type: 'select',
name: 'packageManager',
message: 'Package manager',
choices: [
{ title: 'npm', value: 'npm' },
{ title: 'yarn', value: 'yarn' },
{ title: 'pnpm', value: 'pnpm' },
{ title: 'bun', value: 'bun' },
],
initial: ['npm', 'yarn', 'pnpm', 'bun'].indexOf(detectPackageManager()),
},
], { onCancel });
Object.assign(answers, section1);
// SECTION 2: Site Identity
printSection('Site Identity', 2, TOTAL_SECTIONS);
const section2 = await prompts([
{
type: 'text',
name: 'siteName',
message: 'Site name',
initial: 'My Site',
},
{
type: 'text',
name: 'siteTitle',
message: 'Tagline',
initial: 'A markdown-powered site',
},
{
type: 'text',
name: 'siteDescription',
message: 'Description (one sentence)',
initial: 'A site built with markdown-sync framework.',
},
{
type: 'text',
name: 'siteUrl',
message: 'Site URL',
initial: `https://${answers.projectName}.netlify.app`,
validate: (value: string) => isValidUrl(value) || 'Enter a valid URL',
},
{
type: 'text',
name: 'contactEmail',
message: 'Contact email',
validate: (value: string) => !value || isValidEmail(value) || 'Enter a valid email',
},
], { onCancel });
Object.assign(answers, section2);
// SECTION 3: Creator Info
printSection('Creator Info', 3, TOTAL_SECTIONS);
const section3 = await prompts([
{
type: 'text',
name: 'creatorName',
message: 'Your name',
},
{
type: 'text',
name: 'twitter',
message: 'Twitter/X URL',
initial: 'https://x.com/',
},
{
type: 'text',
name: 'linkedin',
message: 'LinkedIn URL',
initial: 'https://linkedin.com/in/',
},
{
type: 'text',
name: 'github',
message: 'GitHub URL',
initial: 'https://github.com/',
},
], { onCancel });
Object.assign(answers, section3);
// SECTION 4: GitHub Repository
printSection('GitHub Repository', 4, TOTAL_SECTIONS);
const section4 = await prompts([
{
type: 'text',
name: 'githubUsername',
message: 'GitHub username',
initial: extractGitHubUsername(answers.github || ''),
},
{
type: 'text',
name: 'githubRepo',
message: 'Repository name',
initial: answers.projectName,
},
{
type: 'text',
name: 'branch',
message: 'Default branch',
initial: 'main',
},
{
type: 'text',
name: 'contentPath',
message: 'Content path',
initial: 'public/raw',
},
], { onCancel });
Object.assign(answers, section4);
// SECTION 5: Appearance
printSection('Appearance', 5, TOTAL_SECTIONS);
const section5 = await prompts([
{
type: 'select',
name: 'theme',
message: 'Default theme',
choices: [
{ title: 'Tan (warm)', value: 'tan' },
{ title: 'Light', value: 'light' },
{ title: 'Dark', value: 'dark' },
{ title: 'Cloud (blue-gray)', value: 'cloud' },
],
initial: 0,
},
{
type: 'select',
name: 'fontFamily',
message: 'Font family',
choices: [
{ title: 'Sans-serif (system fonts)', value: 'sans' },
{ title: 'Serif (New York)', value: 'serif' },
{ title: 'Monospace (IBM Plex Mono)', value: 'monospace' },
],
initial: 0,
},
{
type: 'text',
name: 'bio',
message: 'Bio text',
initial: 'Your content is instantly available to browsers, LLMs, and AI agents.',
},
], { onCancel });
Object.assign(answers, section5);
// SECTION 6: Homepage & Featured
printSection('Homepage & Featured', 6, TOTAL_SECTIONS);
const section6 = await prompts([
{
type: 'select',
name: 'homepageType',
message: 'Homepage type',
choices: [
{ title: 'Default (standard homepage)', value: 'default' },
{ title: 'Page (use a static page)', value: 'page' },
{ title: 'Post (use a blog post)', value: 'post' },
],
initial: 0,
},
{
type: 'select',
name: 'featuredViewMode',
message: 'Featured section view',
choices: [
{ title: 'Cards (grid with excerpts)', value: 'cards' },
{ title: 'List (bullet list)', value: 'list' },
],
initial: 0,
},
{
type: 'text',
name: 'featuredTitle',
message: 'Featured section title',
initial: 'Get started:',
},
{
type: 'confirm',
name: 'showViewToggle',
message: 'Show view toggle button?',
initial: true,
},
], { onCancel });
Object.assign(answers, section6);
// SECTION 7: Blog & Posts
printSection('Blog & Posts', 7, TOTAL_SECTIONS);
const section7 = await prompts([
{
type: 'confirm',
name: 'blogPageEnabled',
message: 'Enable dedicated /blog page?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'text' : null,
name: 'blogPageTitle',
message: 'Blog page title',
initial: 'Blog',
},
{
type: 'confirm',
name: 'showPostsOnHome',
message: 'Show posts on homepage?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'number' : null,
name: 'homePostsLimit',
message: 'Posts limit on homepage (0 for all)',
initial: 5,
min: 0,
max: 100,
},
{
type: (prev: number, values: { showPostsOnHome: boolean }) =>
values.showPostsOnHome && prev > 0 ? 'confirm' : null,
name: 'homePostsReadMoreEnabled',
message: 'Show "read more" link?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'text' : null,
name: 'homePostsReadMoreText',
message: 'Read more text',
initial: 'Read more blog posts',
},
{
type: (prev: string) => prev ? 'text' : null,
name: 'homePostsReadMoreLink',
message: 'Read more link URL',
initial: '/blog',
},
], { onCancel });
// Set defaults for skipped questions (spread first, then defaults)
Object.assign(answers, {
...section7,
blogPageTitle: section7.blogPageTitle || 'Blog',
homePostsLimit: section7.homePostsLimit ?? 5,
homePostsReadMoreEnabled: section7.homePostsReadMoreEnabled ?? true,
homePostsReadMoreText: section7.homePostsReadMoreText || 'Read more blog posts',
homePostsReadMoreLink: section7.homePostsReadMoreLink || '/blog',
});
// SECTION 8: Features
printSection('Features', 8, TOTAL_SECTIONS);
const section8 = await prompts([
{
type: 'confirm',
name: 'logoGalleryEnabled',
message: 'Enable logo gallery?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'confirm' : null,
name: 'logoGalleryScrolling',
message: 'Scrolling marquee?',
initial: false,
},
{
type: 'confirm',
name: 'githubContributionsEnabled',
message: 'Enable GitHub contributions graph?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'text' : null,
name: 'githubContributionsUsername',
message: 'GitHub username for contributions',
initial: answers.githubUsername,
},
{
type: 'confirm',
name: 'visitorMapEnabled',
message: 'Enable visitor map on stats page?',
initial: false,
},
{
type: 'confirm',
name: 'statsPageEnabled',
message: 'Enable public stats page?',
initial: true,
},
{
type: 'confirm',
name: 'imageLightboxEnabled',
message: 'Enable image lightbox (click to magnify)?',
initial: true,
},
], { onCancel });
Object.assign(answers, {
...section8,
logoGalleryScrolling: section8.logoGalleryScrolling ?? false,
githubContributionsUsername: section8.githubContributionsUsername || answers.githubUsername,
});
// SECTION 9: Footer & Social
printSection('Footer & Social', 9, TOTAL_SECTIONS);
const section9 = await prompts([
{
type: 'confirm',
name: 'footerEnabled',
message: 'Enable footer?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'text' : null,
name: 'footerDefaultContent',
message: 'Footer content (markdown)',
initial: 'Built with [Convex](https://convex.dev) for real-time sync.',
},
{
type: 'confirm',
name: 'socialFooterEnabled',
message: 'Enable social footer (icons + copyright)?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'confirm' : null,
name: 'socialFooterShowInHeader',
message: 'Show social icons in header?',
initial: true,
},
{
type: (prev: boolean, values: { socialFooterEnabled: boolean }) =>
values.socialFooterEnabled ? 'text' : null,
name: 'copyrightSiteName',
message: 'Copyright site name',
initial: answers.siteName,
},
], { onCancel });
Object.assign(answers, {
...section9,
footerDefaultContent: section9.footerDefaultContent || '',
socialFooterShowInHeader: section9.socialFooterShowInHeader ?? true,
copyrightSiteName: section9.copyrightSiteName || answers.siteName,
});
// SECTION 10: Newsletter & Contact
printSection('Newsletter & Contact', 10, TOTAL_SECTIONS);
const section10 = await prompts([
{
type: 'confirm',
name: 'newsletterEnabled',
message: 'Enable newsletter signups?',
initial: false,
hint: 'Requires AgentMail setup',
},
{
type: (prev: boolean) => prev ? 'confirm' : null,
name: 'newsletterHomeEnabled',
message: 'Show signup on homepage?',
initial: true,
},
{
type: 'confirm',
name: 'contactFormEnabled',
message: 'Enable contact form?',
initial: false,
hint: 'Requires AgentMail setup',
},
{
type: (prev: boolean) => prev ? 'text' : null,
name: 'contactFormTitle',
message: 'Contact form title',
initial: 'Get in Touch',
},
], { onCancel });
Object.assign(answers, {
...section10,
newsletterHomeEnabled: section10.newsletterHomeEnabled ?? false,
contactFormTitle: section10.contactFormTitle || 'Get in Touch',
});
// SECTION 11: Advanced Features
printSection('Advanced Features', 11, TOTAL_SECTIONS);
const section11 = await prompts([
{
type: 'confirm',
name: 'mcpServerEnabled',
message: 'Enable MCP server for AI tools?',
initial: true,
},
{
type: 'confirm',
name: 'semanticSearchEnabled',
message: 'Enable semantic search?',
initial: false,
hint: 'Requires OpenAI API key',
},
{
type: 'confirm',
name: 'askAIEnabled',
message: 'Enable Ask AI header button?',
initial: false,
hint: 'Requires semantic search + LLM API key',
},
{
type: 'confirm',
name: 'dashboardEnabled',
message: 'Enable admin dashboard?',
initial: true,
},
{
type: (prev: boolean) => prev ? 'confirm' : null,
name: 'dashboardRequireAuth',
message: 'Require authentication for dashboard?',
initial: false,
hint: 'Requires WorkOS setup',
},
], { onCancel });
Object.assign(answers, {
...section11,
dashboardRequireAuth: section11.dashboardRequireAuth ?? false,
});
// SECTION 12: Twitter/X Config
printSection('Twitter/X Config', 12, TOTAL_SECTIONS);
const section12 = await prompts([
{
type: 'text',
name: 'twitterSite',
message: 'Twitter site handle',
initial: extractTwitterHandle(answers.twitter || ''),
hint: 'For Twitter Cards',
},
{
type: 'text',
name: 'twitterCreator',
message: 'Twitter creator handle',
initial: extractTwitterHandle(answers.twitter || ''),
},
], { onCancel });
Object.assign(answers, section12);
// SECTION 13: Convex Setup
printSection('Convex Setup', 13, TOTAL_SECTIONS);
const section13 = await prompts([
{
type: 'confirm',
name: 'initConvex',
message: 'Initialize Convex project now?',
initial: true,
hint: 'Opens browser for login',
},
{
type: (prev: boolean) => prev ? 'text' : null,
name: 'convexProjectName',
message: 'Convex project name',
initial: answers.projectName,
},
], { onCancel });
Object.assign(answers, {
...section13,
convexProjectName: section13.convexProjectName || answers.projectName,
});
return answers as WizardAnswers;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Two ways to publish: write markdown and sync from the terminal, or use the web dashboard. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify. An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Two ways to publish: write markdown and sync from the terminal, or use the web dashboard. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.

View File

@@ -2,11 +2,93 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
All notable changes to this project. All notable changes to this project.
## v2.19.0
Released January 10, 2026
**npx create-markdown-sync CLI**
Added a CLI tool to scaffold new markdown-sync projects with a single command. Run `npx create-markdown-sync my-site` to clone the template, configure your site through an interactive wizard, install dependencies, and set up Convex.
**Changes:**
- Interactive wizard with 13 sections covering all configuration options
- Clones template from GitHub via giget
- Configures site settings automatically (siteConfig.ts, fork-config.json)
- Installs dependencies with your preferred package manager
- Sets up Convex project with optional WorkOS auth (disabled by default)
- Starts dev server and opens browser
- Clear next steps with links to docs, deployment guide, and WorkOS setup
**Files changed:**
- `packages/create-markdown-sync/` - New monorepo package
- `packages/create-markdown-sync/src/index.ts` - CLI entry point
- `packages/create-markdown-sync/src/wizard.ts` - Interactive prompts
- `packages/create-markdown-sync/src/clone.ts` - Template cloning
- `packages/create-markdown-sync/src/configure.ts` - Site configuration
- `packages/create-markdown-sync/src/install.ts` - Dependency installation
- `packages/create-markdown-sync/src/convex-setup.ts` - Convex initialization
- `packages/create-markdown-sync/src/utils.ts` - Helper utilities
- `package.json` - Added workspaces configuration
- `.gitignore` - Added packages/*/dist/ and packages/*/node_modules/
---
## v2.18.2
Released January 10, 2026
**Related posts thumbnail view with toggle**
Added a new thumbnail view for related posts at the bottom of blog posts. Shows post image, title, description, author avatar, author name, and date. Users can toggle between the new thumbnail view and the existing list view, with their preference saved to localStorage.
**Changes:**
- New thumbnail view with image on left, content on right (like attached screenshot)
- Toggle button using same icons as homepage featured section
- View preference saved to localStorage
- Configuration via siteConfig.relatedPosts (defaultViewMode, showViewToggle)
- Dashboard Config section for related posts settings
**Files changed:**
- `convex/posts.ts` - Updated getRelatedPosts query with image, excerpt, authorName, authorImage
- `src/config/siteConfig.ts` - Added RelatedPostsConfig interface and relatedPosts config
- `src/pages/Post.tsx` - Added thumbnail view, toggle state, and view mode rendering
- `src/pages/Dashboard.tsx` - Added Related Posts config card in ConfigSection
- `src/styles/global.css` - Added thumbnail view CSS styles
---
## v2.18.1
Released January 10, 2026
**README.md streamlined with docs links**
Reduced README from 609 lines to 155 lines. Detailed documentation now lives on the live site at markdown.fast/docs.
**Changes:**
- Added Documentation section with link to markdown.fast/docs
- Added Guides subsection with links to Setup, Fork Configuration, Dashboard, WorkOS, MCP Server, and AgentMail guides
- Simplified Features section to brief summary with link to About page
- Simplified Fork Configuration to quick commands with doc link
- Kept sync commands, setup, and Netlify deployment sections
- Removed sections now covered by live docs
**Files changed:**
- `README.md` - Streamlined from 609 to 155 lines
---
## v2.18.0 ## v2.18.0
Released January 10, 2026 Released January 10, 2026

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
You found the contact page. Nice You found the contact page. Nice

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Ask AI ## Ask AI

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Configuration ## Configuration

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Content ## Content

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Dashboard ## Dashboard

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Deployment ## Deployment

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Frontmatter ## Frontmatter

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
Set up image uploads for the dashboard using ConvexFS and Bunny.net CDN. Set up image uploads for the dashboard using ConvexFS and Bunny.net CDN.

View File

@@ -1,5 +1,7 @@
# OpenCode Integration # OpenCode Integration
> This framework includes full OpenCode support with agents, commands, skills, and plugins.
--- ---
Type: post Type: post
Date: 2026-01-10 Date: 2026-01-10

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Keyword Search ## Keyword Search

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Semantic Search ## Semantic Search

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
## Getting started ## Getting started

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
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). 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).

View File

@@ -13,9 +13,37 @@ Tags: configuration, setup, fork, tutorial
After forking this markdown framework, you need to update configuration files with your site information. This affects your site name, URLs, RSS feeds, social sharing metadata, and AI discovery files. After forking this markdown framework, you need to update configuration files with your site information. This affects your site name, URLs, RSS feeds, social sharing metadata, and AI discovery files.
Previously this meant editing 10+ files manually. Now you have two options. Previously this meant editing 10+ files manually. Now you have three options.
## Option 1: Automated configuration ## Option 1: npx CLI (Recommended)
The fastest way to get started. Run a single command to create a new project:
```bash
npx create-markdown-sync my-site
```
The interactive wizard will:
1. Clone the template repository
2. Walk through all configuration options (site name, URLs, features, etc.)
3. Install dependencies
4. Set up Convex (opens browser for login)
5. Start the dev server and open your browser
After setup, follow the on-screen instructions:
```bash
cd my-site
npx convex dev # Start Convex (required first time)
npm run sync # Sync content (in another terminal)
npm run dev # Start dev server
```
**Resources:**
- Deployment: https://www.markdown.fast/docs-deployment
- WorkOS auth: https://www.markdown.fast/how-to-setup-workos
## Option 2: Automated configuration
Run a single command to configure everything at once. Run a single command to configure everything at once.
@@ -81,7 +109,7 @@ Updating public/.well-known/ai-plugin.json...
Configuration complete! Configuration complete!
``` ```
## Option 2: Manual configuration ## Option 3: Manual configuration
If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It includes: If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It includes:
@@ -332,11 +360,12 @@ If you want to clear the sample content, delete the markdown files in those dire
## Summary ## Summary
Two options after forking: Three options to get started:
1. **Automated**: `cp fork-config.json.example fork-config.json`, edit JSON, run `npm run configure` 1. **npx CLI (Recommended)**: `npx create-markdown-sync my-site` - interactive wizard creates and configures everything
2. **Manual**: Follow `FORK_CONFIG.md` step-by-step or paste the AI prompt into Claude/ChatGPT 2. **Automated**: `cp fork-config.json.example fork-config.json`, edit JSON, run `npm run configure`
3. **Manual**: Follow `FORK_CONFIG.md` step-by-step or paste the AI prompt into Claude/ChatGPT
Both approaches update the same 11 files. The automated option takes about 30 seconds. The manual option gives you more control over each change. The npx CLI is the fastest option for new projects. The automated and manual options work best for existing forks.
Fork it, configure it, ship it. Fork it, configure it, ship it.

View File

@@ -2,12 +2,12 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**. The open-source markdown publishing framework for developers and AI agents to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)** that's always in sync.
Write markdown, sync from the terminal. **[Fork it](https://github.com/waynesutton/markdown-site)**, customize it, ship it. **[Fork it](https://github.com/waynesutton/markdown-site)** or npm <span class="copy-command">npx create-markdown-sync my-site</span>, customize it, ship it.
<!-- This is a comments <!-- This is a comments
Your content is instantly available to browsers, LLMs, and AI Your content is instantly available to browsers, LLMs, and AI
@@ -31,4 +31,8 @@ agents. -->
**Semantic search** - Find content by meaning, not just keywords. **Semantic search** - Find content by meaning, not just keywords.
**Ask AI** - Chat with your site content. Get answers with sources. **Ask AI** - Chat with your site content. Get answers with sources.
```
```

View File

@@ -1,8 +1,8 @@
# Homepage # Homepage
An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**. The open-source markdown publishing framework for developers and AI agents to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)** that's always in sync.
Write markdown, sync from the terminal. **[Fork it](https://github.com/waynesutton/markdown-site)**, customize it, ship it. **[Fork it](https://github.com/waynesutton/markdown-site)** or npm <span class="copy-command">npx create-markdown-sync my-site</span>, customize it, ship it.
<!-- This is a comments <!-- This is a comments
Your content is instantly available to browsers, LLMs, and AI Your content is instantly available to browsers, LLMs, and AI
@@ -28,11 +28,15 @@ agents. -->
**Ask AI** - Chat with your site content. Get answers with sources. **Ask AI** - Chat with your site content. Get answers with sources.
```
```
--- ---
## Blog Posts (20) ## Blog Posts (20)
- **[OpenCode Integration](/raw/docs-opencode.md)** - **[OpenCode Integration](/raw/docs-opencode.md)** - This framework includes full OpenCode support with agents, commands, skills, and plugins.
- Date: 2026-01-10 | Reading time: 4 min read | Tags: opencode, plugins, terminal - Date: 2026-01-10 | Reading time: 4 min read | Tags: opencode, plugins, terminal
- **[How to Use Code Blocks](/raw/how-to-use-code-blocks.md)** - A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts. - **[How to Use Code Blocks](/raw/how-to-use-code-blocks.md)** - A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts.
- Date: 2026-01-07 | Reading time: 4 min read | Tags: tutorial, markdown, code, syntax-highlighting - Date: 2026-01-07 | Reading time: 4 min read | Tags: tutorial, markdown, code, syntax-highlighting

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
# Newsletter Demo Page # Newsletter Demo Page

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-10 Date: 2026-01-11
--- ---
This markdown framework is open source and built to be extended. Here is what ships out of the box. This markdown framework is open source and built to be extended. Here is what ships out of the box.

View File

@@ -29,6 +29,19 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Silent mode flag (for CLI usage)
const silent = process.argv.includes("--silent");
// Log helper that respects silent mode
function log(message: string): void {
if (!silent) log(message);
}
// Warn helper that always shows warnings
function warn(message: string): void {
warn(message);
}
// Configuration interface matching fork-config.json // Configuration interface matching fork-config.json
interface ForkConfig { interface ForkConfig {
siteName: string; siteName: string;
@@ -112,10 +125,10 @@ function readConfig(): ForkConfig {
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
console.error("Error: fork-config.json not found."); console.error("Error: fork-config.json not found.");
console.log("\nTo get started:"); log("\nTo get started:");
console.log("1. Copy fork-config.json.example to fork-config.json"); log("1. Copy fork-config.json.example to fork-config.json");
console.log("2. Edit fork-config.json with your site information"); log("2. Edit fork-config.json with your site information");
console.log("3. Run npm run configure again"); log("3. Run npm run configure again");
process.exit(1); process.exit(1);
} }
@@ -131,7 +144,7 @@ function updateFile(
const filePath = path.join(PROJECT_ROOT, relativePath); const filePath = path.join(PROJECT_ROOT, relativePath);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
console.warn(`Warning: ${relativePath} not found, skipping.`); warn(`Warning: ${relativePath} not found, skipping.`);
return; return;
} }
@@ -148,29 +161,37 @@ function updateFile(
if (modified) { if (modified) {
fs.writeFileSync(filePath, content, "utf-8"); fs.writeFileSync(filePath, content, "utf-8");
console.log(` Updated: ${relativePath}`); log(` Updated: ${relativePath}`);
} else { } else {
console.log(` No changes: ${relativePath}`); log(` No changes: ${relativePath}`);
} }
} }
// Update siteConfig.ts // Update siteConfig.ts
function updateSiteConfig(config: ForkConfig): void { function updateSiteConfig(config: ForkConfig): void {
console.log("\nUpdating src/config/siteConfig.ts..."); log("\nUpdating src/config/siteConfig.ts...");
const filePath = path.join(PROJECT_ROOT, "src/config/siteConfig.ts"); const filePath = path.join(PROJECT_ROOT, "src/config/siteConfig.ts");
let content = fs.readFileSync(filePath, "utf-8"); let content = fs.readFileSync(filePath, "utf-8");
// Update site name // Update site name (match single-quoted or double-quoted strings properly)
content = content.replace( content = content.replace(
/name: ['"].*?['"]/, /name: '(?:[^'\\]|\\.)*'/,
`name: '${config.siteName}'`, `name: '${config.siteName.replace(/'/g, "\\'")}'`,
);
content = content.replace(
/name: "(?:[^"\\]|\\.)*"/,
`name: "${config.siteName.replace(/"/g, '\\"')}"`,
); );
// Update site title // Update site title (match single-quoted or double-quoted strings properly)
content = content.replace( content = content.replace(
/title: ['"].*?['"]/, /title: '(?:[^'\\]|\\.)*'/,
`title: "${config.siteTitle}"`, `title: "${config.siteTitle.replace(/"/g, '\\"')}"`,
);
content = content.replace(
/title: "(?:[^"\\]|\\.)*"/,
`title: "${config.siteTitle.replace(/"/g, '\\"')}"`,
); );
// Update bio // Update bio
@@ -389,12 +410,12 @@ function updateSiteConfig(config: ForkConfig): void {
} }
fs.writeFileSync(filePath, content, "utf-8"); fs.writeFileSync(filePath, content, "utf-8");
console.log(` Updated: src/config/siteConfig.ts`); log(` Updated: src/config/siteConfig.ts`);
} }
// Update Home.tsx // Update Home.tsx
function updateHomeTsx(config: ForkConfig): void { function updateHomeTsx(config: ForkConfig): void {
console.log("\nUpdating src/pages/Home.tsx..."); log("\nUpdating src/pages/Home.tsx...");
const githubRepoUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`; const githubRepoUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`;
@@ -439,7 +460,7 @@ function updateHomeTsx(config: ForkConfig): void {
// Update Post.tsx // Update Post.tsx
function updatePostTsx(config: ForkConfig): void { function updatePostTsx(config: ForkConfig): void {
console.log("\nUpdating src/pages/Post.tsx..."); log("\nUpdating src/pages/Post.tsx...");
updateFile("src/pages/Post.tsx", [ updateFile("src/pages/Post.tsx", [
// Match any existing SITE_URL value (https://...) // Match any existing SITE_URL value (https://...)
@@ -457,7 +478,7 @@ function updatePostTsx(config: ForkConfig): void {
// Update DocsPage.tsx // Update DocsPage.tsx
function updateDocsPageTsx(config: ForkConfig): void { function updateDocsPageTsx(config: ForkConfig): void {
console.log("\nUpdating src/pages/DocsPage.tsx..."); log("\nUpdating src/pages/DocsPage.tsx...");
updateFile("src/pages/DocsPage.tsx", [ updateFile("src/pages/DocsPage.tsx", [
// Match any existing SITE_URL value (https://...) // Match any existing SITE_URL value (https://...)
@@ -470,7 +491,7 @@ function updateDocsPageTsx(config: ForkConfig): void {
// Update convex/http.ts // Update convex/http.ts
function updateConvexHttp(config: ForkConfig): void { function updateConvexHttp(config: ForkConfig): void {
console.log("\nUpdating convex/http.ts..."); log("\nUpdating convex/http.ts...");
updateFile("convex/http.ts", [ updateFile("convex/http.ts", [
// Match any existing SITE_URL value with process.env fallback // Match any existing SITE_URL value with process.env fallback
@@ -503,7 +524,7 @@ function updateConvexHttp(config: ForkConfig): void {
// Update convex/rss.ts // Update convex/rss.ts
function updateConvexRss(config: ForkConfig): void { function updateConvexRss(config: ForkConfig): void {
console.log("\nUpdating convex/rss.ts..."); log("\nUpdating convex/rss.ts...");
updateFile("convex/rss.ts", [ updateFile("convex/rss.ts", [
// Match any existing SITE_URL value with process.env fallback // Match any existing SITE_URL value with process.env fallback
@@ -526,7 +547,7 @@ function updateConvexRss(config: ForkConfig): void {
// Update index.html // Update index.html
function updateIndexHtml(config: ForkConfig): void { function updateIndexHtml(config: ForkConfig): void {
console.log("\nUpdating index.html..."); log("\nUpdating index.html...");
const replacements: Array<{ search: string | RegExp; replace: string }> = [ const replacements: Array<{ search: string | RegExp; replace: string }> = [
// Meta description (match any content) // Meta description (match any content)
@@ -626,7 +647,7 @@ function updateIndexHtml(config: ForkConfig): void {
// Update public/llms.txt // Update public/llms.txt
function updateLlmsTxt(config: ForkConfig): void { function updateLlmsTxt(config: ForkConfig): void {
console.log("\nUpdating public/llms.txt..."); log("\nUpdating public/llms.txt...");
const githubUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`; const githubUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`;
@@ -713,12 +734,12 @@ Each post contains:
const filePath = path.join(PROJECT_ROOT, "public/llms.txt"); const filePath = path.join(PROJECT_ROOT, "public/llms.txt");
fs.writeFileSync(filePath, content, "utf-8"); fs.writeFileSync(filePath, content, "utf-8");
console.log(` Updated: public/llms.txt`); log(` Updated: public/llms.txt`);
} }
// Update public/robots.txt // Update public/robots.txt
function updateRobotsTxt(config: ForkConfig): void { function updateRobotsTxt(config: ForkConfig): void {
console.log("\nUpdating public/robots.txt..."); log("\nUpdating public/robots.txt...");
const content = `# robots.txt for ${config.siteName} const content = `# robots.txt for ${config.siteName}
# https://www.robotstxt.org/ # https://www.robotstxt.org/
@@ -757,12 +778,12 @@ Crawl-delay: 1
const filePath = path.join(PROJECT_ROOT, "public/robots.txt"); const filePath = path.join(PROJECT_ROOT, "public/robots.txt");
fs.writeFileSync(filePath, content, "utf-8"); fs.writeFileSync(filePath, content, "utf-8");
console.log(` Updated: public/robots.txt`); log(` Updated: public/robots.txt`);
} }
// Update public/openapi.yaml // Update public/openapi.yaml
function updateOpenApiYaml(config: ForkConfig): void { function updateOpenApiYaml(config: ForkConfig): void {
console.log("\nUpdating public/openapi.yaml..."); log("\nUpdating public/openapi.yaml...");
const githubUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`; const githubUrl = `https://github.com/${config.githubUsername}/${config.githubRepo}`;
// Extract domain from siteUrl for example URLs (without www. if present) // Extract domain from siteUrl for example URLs (without www. if present)
@@ -814,7 +835,7 @@ function updateOpenApiYaml(config: ForkConfig): void {
// Update public/.well-known/ai-plugin.json // Update public/.well-known/ai-plugin.json
function updateAiPluginJson(config: ForkConfig): void { function updateAiPluginJson(config: ForkConfig): void {
console.log("\nUpdating public/.well-known/ai-plugin.json..."); log("\nUpdating public/.well-known/ai-plugin.json...");
const pluginName = config.siteName.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""); const pluginName = config.siteName.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "");
@@ -838,14 +859,14 @@ function updateAiPluginJson(config: ForkConfig): void {
const filePath = path.join(PROJECT_ROOT, "public/.well-known/ai-plugin.json"); const filePath = path.join(PROJECT_ROOT, "public/.well-known/ai-plugin.json");
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + "\n", "utf-8"); fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + "\n", "utf-8");
console.log(` Updated: public/.well-known/ai-plugin.json`); log(` Updated: public/.well-known/ai-plugin.json`);
} }
// Update default theme in siteConfig.ts // Update default theme in siteConfig.ts
function updateThemeConfig(config: ForkConfig): void { function updateThemeConfig(config: ForkConfig): void {
if (!config.theme) return; if (!config.theme) return;
console.log("\nUpdating default theme in src/config/siteConfig.ts..."); log("\nUpdating default theme in src/config/siteConfig.ts...");
updateFile("src/config/siteConfig.ts", [ updateFile("src/config/siteConfig.ts", [
{ {
@@ -857,7 +878,7 @@ function updateThemeConfig(config: ForkConfig): void {
// Update netlify/edge-functions/mcp.ts // Update netlify/edge-functions/mcp.ts
function updateMcpEdgeFunction(config: ForkConfig): void { function updateMcpEdgeFunction(config: ForkConfig): void {
console.log("\nUpdating netlify/edge-functions/mcp.ts..."); log("\nUpdating netlify/edge-functions/mcp.ts...");
updateFile("netlify/edge-functions/mcp.ts", [ updateFile("netlify/edge-functions/mcp.ts", [
// Match any existing SITE_URL constant // Match any existing SITE_URL constant
@@ -880,7 +901,7 @@ function updateMcpEdgeFunction(config: ForkConfig): void {
// Update scripts/send-newsletter.ts // Update scripts/send-newsletter.ts
function updateSendNewsletter(config: ForkConfig): void { function updateSendNewsletter(config: ForkConfig): void {
console.log("\nUpdating scripts/send-newsletter.ts..."); log("\nUpdating scripts/send-newsletter.ts...");
updateFile("scripts/send-newsletter.ts", [ updateFile("scripts/send-newsletter.ts", [
// Match any existing SITE_URL fallback in comment // Match any existing SITE_URL fallback in comment
@@ -898,13 +919,13 @@ function updateSendNewsletter(config: ForkConfig): void {
// Main function // Main function
function main(): void { function main(): void {
console.log("Fork Configuration Script"); log("Fork Configuration Script");
console.log("=========================\n"); log("=========================\n");
// Read configuration // Read configuration
const config = readConfig(); const config = readConfig();
console.log(`Configuring site: ${config.siteName}`); log(`Configuring site: ${config.siteName}`);
console.log(`URL: ${config.siteUrl}`); log(`URL: ${config.siteUrl}`);
// Apply updates to all files // Apply updates to all files
updateSiteConfig(config); updateSiteConfig(config);
@@ -922,14 +943,14 @@ function main(): void {
updateMcpEdgeFunction(config); updateMcpEdgeFunction(config);
updateSendNewsletter(config); updateSendNewsletter(config);
console.log("\n========================="); log("\n=========================");
console.log("Configuration complete!"); log("Configuration complete!");
console.log("\nNext steps:"); log("\nNext steps:");
console.log("1. Review the changes with: git diff"); log("1. Review the changes with: git diff");
console.log("2. Run: npx convex dev (if not already running)"); log("2. Run: npx convex dev (if not already running)");
console.log("3. Run: npm run sync (to sync content to development)"); log("3. Run: npm run sync (to sync content to development)");
console.log("4. Run: npm run dev (to start the dev server)"); log("4. Run: npm run dev (to start the dev server)");
console.log("5. Deploy to Netlify when ready"); log("5. Deploy to Netlify when ready");
} }
main(); main();

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
@@ -135,7 +136,7 @@ function InlineCopyButton({ command }: { command: string }) {
); );
} }
// Image lightbox component // Image lightbox component - uses portal to escape contain: layout
function ImageLightbox({ function ImageLightbox({
src, src,
alt, alt,
@@ -165,7 +166,8 @@ function ImageLightbox({
}; };
}, [onClose]); }, [onClose]);
return ( // Use portal to render at document body level, escaping contain: layout
return createPortal(
<div className="image-lightbox-backdrop" onClick={handleBackdropClick}> <div className="image-lightbox-backdrop" onClick={handleBackdropClick}>
<button <button
className="image-lightbox-close" className="image-lightbox-close"
@@ -178,7 +180,8 @@ function ImageLightbox({
<img src={src} alt={alt} className="image-lightbox-image" /> <img src={src} alt={alt} className="image-lightbox-image" />
{alt && <div className="image-lightbox-caption">{alt}</div>} {alt && <div className="image-lightbox-caption">{alt}</div>}
</div> </div>
</div> </div>,
document.body
); );
} }

View File

@@ -11,6 +11,21 @@ const THEME_MAP: Record<string, "dark" | "light"> = {
cloud: "light", cloud: "light",
}; };
// Check if content is a valid unified diff format
// Valid diffs have headers like "diff --git", "---", "+++", or "@@"
function isValidDiff(code: string): boolean {
const lines = code.trim().split("\n");
// Check for common diff indicators
const hasDiffHeader = lines.some(
(line) =>
line.startsWith("diff ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ") ||
line.startsWith("@@ ")
);
return hasDiffHeader;
}
interface DiffCodeBlockProps { interface DiffCodeBlockProps {
code: string; code: string;
language: "diff" | "patch"; language: "diff" | "patch";
@@ -30,6 +45,38 @@ export default function DiffCodeBlock({ code, language }: DiffCodeBlockProps) {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
// If not a valid diff format, render as simple code block with diff styling
if (!isValidDiff(code)) {
return (
<div className="code-block-wrapper">
<span className="code-language">{language}</span>
<button
className="code-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
<pre className="diff-fallback">
<code>
{code.split("\n").map((line, i) => {
let className = "";
if (line.startsWith("+")) className = "diff-added";
else if (line.startsWith("-")) className = "diff-removed";
return (
<span key={i} className={className}>
{line}
{"\n"}
</span>
);
})}
</code>
</pre>
</div>
);
}
return ( return (
<div className="diff-block-wrapper" data-theme-type={themeType}> <div className="diff-block-wrapper" data-theme-type={themeType}>
<div className="diff-block-header"> <div className="diff-block-header">

View File

@@ -1,10 +1,179 @@
import React, { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash";
import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript";
import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript";
import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown";
import diff from "react-syntax-highlighter/dist/esm/languages/prism/diff";
import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
import { Copy, Check } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
import DiffCodeBlock from "./DiffCodeBlock";
import siteConfig from "../config/siteConfig"; import siteConfig from "../config/siteConfig";
// Register languages for syntax highlighting
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("shell", bash);
SyntaxHighlighter.registerLanguage("sh", bash);
SyntaxHighlighter.registerLanguage("javascript", javascript);
SyntaxHighlighter.registerLanguage("js", javascript);
SyntaxHighlighter.registerLanguage("typescript", typescript);
SyntaxHighlighter.registerLanguage("ts", typescript);
SyntaxHighlighter.registerLanguage("markdown", markdown);
SyntaxHighlighter.registerLanguage("md", markdown);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("json", json);
// Cursor Dark Theme for syntax highlighting
const cursorDarkTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#e4e4e7",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "0.9em",
textAlign: "left",
whiteSpace: "pre",
wordSpacing: "normal",
wordBreak: "normal",
wordWrap: "normal",
lineHeight: "1.6",
tabSize: 2,
},
'pre[class*="language-"]': {
color: "#e4e4e7",
background: "#18181b",
padding: "1.25em",
margin: "0",
overflow: "auto",
borderRadius: "0.5rem",
},
comment: { color: "#71717a" },
punctuation: { color: "#a1a1aa" },
property: { color: "#93c5fd" },
string: { color: "#86efac" },
keyword: { color: "#c4b5fd" },
function: { color: "#fcd34d" },
number: { color: "#fdba74" },
operator: { color: "#f9a8d4" },
"class-name": { color: "#93c5fd" },
boolean: { color: "#fdba74" },
variable: { color: "#e4e4e7" },
"attr-name": { color: "#93c5fd" },
"attr-value": { color: "#86efac" },
tag: { color: "#f87171" },
deleted: { color: "#f87171", background: "rgba(248, 113, 113, 0.1)" },
inserted: { color: "#86efac", background: "rgba(134, 239, 172, 0.1)" },
};
// Cursor Light Theme for syntax highlighting
const cursorLightTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#27272a",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "0.9em",
textAlign: "left",
whiteSpace: "pre",
wordSpacing: "normal",
wordBreak: "normal",
wordWrap: "normal",
lineHeight: "1.6",
tabSize: 2,
},
'pre[class*="language-"]': {
color: "#27272a",
background: "#f4f4f5",
padding: "1.25em",
margin: "0",
overflow: "auto",
borderRadius: "0.5rem",
},
comment: { color: "#71717a" },
punctuation: { color: "#52525b" },
property: { color: "#2563eb" },
string: { color: "#16a34a" },
keyword: { color: "#7c3aed" },
function: { color: "#ca8a04" },
number: { color: "#ea580c" },
operator: { color: "#db2777" },
"class-name": { color: "#2563eb" },
boolean: { color: "#ea580c" },
variable: { color: "#27272a" },
"attr-name": { color: "#2563eb" },
"attr-value": { color: "#16a34a" },
tag: { color: "#dc2626" },
deleted: { color: "#dc2626", background: "rgba(220, 38, 38, 0.1)" },
inserted: { color: "#16a34a", background: "rgba(22, 163, 74, 0.1)" },
};
// Tan Theme for syntax highlighting
const cursorTanTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#44403c",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "0.9em",
textAlign: "left",
whiteSpace: "pre",
wordSpacing: "normal",
wordBreak: "normal",
wordWrap: "normal",
lineHeight: "1.6",
tabSize: 2,
},
'pre[class*="language-"]': {
color: "#44403c",
background: "#f5f5f0",
padding: "1.25em",
margin: "0",
overflow: "auto",
borderRadius: "0.5rem",
},
comment: { color: "#78716c" },
punctuation: { color: "#57534e" },
property: { color: "#1d4ed8" },
string: { color: "#15803d" },
keyword: { color: "#6d28d9" },
function: { color: "#a16207" },
number: { color: "#c2410c" },
operator: { color: "#be185d" },
"class-name": { color: "#1d4ed8" },
boolean: { color: "#c2410c" },
variable: { color: "#44403c" },
"attr-name": { color: "#1d4ed8" },
"attr-value": { color: "#15803d" },
tag: { color: "#b91c1c" },
deleted: { color: "#b91c1c", background: "rgba(185, 28, 28, 0.1)" },
inserted: { color: "#15803d", background: "rgba(21, 128, 61, 0.1)" },
};
// Copy button component for code blocks
function CodeCopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
className="code-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
);
}
// Sanitize schema for footer markdown (allows links, paragraphs, line breaks, images) // Sanitize schema for footer markdown (allows links, paragraphs, line breaks, images)
// style attribute is sanitized by rehypeSanitize to remove dangerous CSS // style attribute is sanitized by rehypeSanitize to remove dangerous CSS
const footerSanitizeSchema = { const footerSanitizeSchema = {
@@ -25,8 +194,23 @@ interface FooterProps {
} }
export default function Footer({ content }: FooterProps) { export default function Footer({ content }: FooterProps) {
const { theme } = useTheme();
const { footer } = siteConfig; const { footer } = siteConfig;
// Get code theme based on current theme
const getCodeTheme = () => {
switch (theme) {
case "dark":
return cursorDarkTheme;
case "light":
return cursorLightTheme;
case "tan":
return cursorTanTheme;
default:
return cursorDarkTheme;
}
};
// Don't render if footer is globally disabled // Don't render if footer is globally disabled
if (!footer.enabled) { if (!footer.enabled) {
return null; return null;
@@ -81,6 +265,77 @@ export default function Footer({ content }: FooterProps) {
</a> </a>
); );
}, },
// Code blocks with syntax highlighting
code(codeProps) {
const { className, children, style, ...restProps } =
codeProps as {
className?: string;
children?: React.ReactNode;
style?: React.CSSProperties;
};
const match = /language-(\w+)/.exec(className || "");
// Detect inline code vs code blocks
const codeContent = String(children);
const hasNewlines = codeContent.includes("\n");
const isShort = codeContent.length < 80;
const hasLanguage = !!match || !!className;
// It's inline only if: no language, short content, no newlines
const isInline = !hasLanguage && isShort && !hasNewlines;
if (isInline) {
return (
<code className="inline-code" style={style} {...restProps}>
{children}
</code>
);
}
const codeString = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text";
// Route diff/patch to DiffCodeBlock for enhanced diff rendering
if (language === "diff" || language === "patch") {
return (
<DiffCodeBlock
code={codeString}
language={language as "diff" | "patch"}
/>
);
}
const isTextBlock = language === "text";
// Custom styles for text blocks to enable wrapping
const textBlockStyle = isTextBlock
? {
whiteSpace: "pre-wrap" as const,
wordWrap: "break-word" as const,
overflowWrap: "break-word" as const,
}
: {};
return (
<div
className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}
>
{match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} />
<SyntaxHighlighter
style={getCodeTheme()}
language={language}
PreTag="div"
customStyle={textBlockStyle}
codeTagProps={
isTextBlock ? { style: textBlockStyle } : undefined
}
>
{codeString}
</SyntaxHighlighter>
</div>
);
},
}} }}
> >
{footerContent} {footerContent}

View File

@@ -272,6 +272,13 @@ export interface AskAIConfig {
models: AIModelOption[]; // Available models for Ask AI models: AIModelOption[]; // Available models for Ask AI
} }
// Related posts configuration
// Controls the display of related posts at the bottom of blog posts
export interface RelatedPostsConfig {
defaultViewMode: "list" | "thumbnails"; // Default view mode for related posts
showViewToggle: boolean; // Show toggle button to switch between views
}
// Social link configuration for social footer // Social link configuration for social footer
export interface SocialLink { export interface SocialLink {
platform: platform:
@@ -413,13 +420,16 @@ export interface SiteConfig {
// Ask AI configuration (optional) // Ask AI configuration (optional)
askAI?: AskAIConfig; askAI?: AskAIConfig;
// Related posts configuration (optional)
relatedPosts?: RelatedPostsConfig;
} }
// Default site configuration // Default site configuration
// Customize this for your site // Customize this for your site
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
// Basic site info // Basic site info
name: 'markdown "sync" framework', name: "markdown sync",
title: "markdown sync framework", title: "markdown sync framework",
// Optional logo/header image (place in public/images/, set to null to hide) // Optional logo/header image (place in public/images/, set to null to hide)
logo: "/images/logo.svg", logo: "/images/logo.svg",
@@ -836,6 +846,13 @@ export const siteConfig: SiteConfig = {
}, },
], ],
}, },
// Related posts configuration
// Controls the display of related posts at the bottom of blog posts
relatedPosts: {
defaultViewMode: "thumbnails", // Default view: "list" or "thumbnails"
showViewToggle: true, // Show toggle button to switch between views
},
}; };
// Export the config as default for easy importing // Export the config as default for easy importing

View File

@@ -276,6 +276,7 @@ interface ConfirmDeleteModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
onCopy: () => void;
title: string; title: string;
itemName: string; itemName: string;
itemType: "post" | "page"; itemType: "post" | "page";
@@ -286,11 +287,20 @@ function ConfirmDeleteModal({
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
onCopy,
title, title,
itemName, itemName,
itemType, itemType,
isDeleting, isDeleting,
}: ConfirmDeleteModalProps) { }: ConfirmDeleteModalProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await onCopy();
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleBackdropClick = (e: React.MouseEvent) => { const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isDeleting) { if (e.target === e.currentTarget && !isDeleting) {
onClose(); onClose();
@@ -309,6 +319,13 @@ function ConfirmDeleteModal({
return () => document.removeEventListener("keydown", handleEsc); return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose, isDeleting]); }, [isOpen, onClose, isDeleting]);
// Reset copied state when modal closes
useEffect(() => {
if (!isOpen) {
setCopied(false);
}
}, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -340,6 +357,30 @@ function ConfirmDeleteModal({
This action cannot be undone. The {itemType} will be permanently This action cannot be undone. The {itemType} will be permanently
removed from the database. removed from the database.
</p> </p>
<div className="dashboard-modal-copy-prompt">
<div className="dashboard-modal-copy-prompt-text">
<Info size={16} />
<span>Would you like to copy the markdown before deleting?</span>
</div>
<button
className={`dashboard-modal-copy-btn ${copied ? "copied" : ""}`}
onClick={handleCopy}
disabled={isDeleting}
title="Copy markdown to clipboard"
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
) : (
<>
<CopySimple size={16} />
<span>Copy Markdown</span>
</>
)}
</button>
</div>
</div> </div>
<div className="dashboard-modal-footer"> <div className="dashboard-modal-footer">
@@ -670,7 +711,8 @@ function DashboardContent() {
id: string; id: string;
title: string; title: string;
type: "post" | "page"; type: "post" | "page";
}>({ isOpen: false, id: "", title: "", type: "post" }); item: ContentItem | null;
}>({ isOpen: false, id: "", title: "", type: "post", item: null });
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// Sync server state // Sync server state
@@ -862,12 +904,13 @@ function DashboardContent() {
// Show delete confirmation modal for a post // Show delete confirmation modal for a post
const handleDeletePost = useCallback( const handleDeletePost = useCallback(
(id: string, title: string) => { (item: ContentItem) => {
setDeleteModal({ setDeleteModal({
isOpen: true, isOpen: true,
id, id: item._id,
title, title: item.title,
type: "post", type: "post",
item,
}); });
}, },
[], [],
@@ -875,12 +918,13 @@ function DashboardContent() {
// Show delete confirmation modal for a page // Show delete confirmation modal for a page
const handleDeletePage = useCallback( const handleDeletePage = useCallback(
(id: string, title: string) => { (item: ContentItem) => {
setDeleteModal({ setDeleteModal({
isOpen: true, isOpen: true,
id, id: item._id,
title, title: item.title,
type: "page", type: "page",
item,
}); });
}, },
[], [],
@@ -889,7 +933,7 @@ function DashboardContent() {
// Close delete modal // Close delete modal
const closeDeleteModal = useCallback(() => { const closeDeleteModal = useCallback(() => {
if (!isDeleting) { if (!isDeleting) {
setDeleteModal({ isOpen: false, id: "", title: "", type: "post" }); setDeleteModal({ isOpen: false, id: "", title: "", type: "post", item: null });
} }
}, [isDeleting]); }, [isDeleting]);
@@ -904,7 +948,7 @@ function DashboardContent() {
await deletePageMutation({ id: deleteModal.id as Id<"pages"> }); await deletePageMutation({ id: deleteModal.id as Id<"pages"> });
addToast("Page deleted successfully", "success"); addToast("Page deleted successfully", "success");
} }
setDeleteModal({ isOpen: false, id: "", title: "", type: "post" }); setDeleteModal({ isOpen: false, id: "", title: "", type: "post", item: null });
} catch (error) { } catch (error) {
addToast( addToast(
error instanceof Error ? error.message : `Failed to delete ${deleteModal.type}`, error instanceof Error ? error.message : `Failed to delete ${deleteModal.type}`,
@@ -1005,6 +1049,14 @@ function DashboardContent() {
[], [],
); );
// Copy markdown content before deletion
const handleCopyBeforeDelete = useCallback(async () => {
if (!deleteModal.item) return;
const markdown = generateMarkdown(deleteModal.item, deleteModal.type);
await navigator.clipboard.writeText(markdown);
addToast("Markdown copied to clipboard", "success");
}, [deleteModal, generateMarkdown, addToast]);
// Download markdown file // Download markdown file
const handleDownloadMarkdown = useCallback(() => { const handleDownloadMarkdown = useCallback(() => {
if (!editingItem) return; if (!editingItem) return;
@@ -1166,6 +1218,7 @@ function DashboardContent() {
isOpen={deleteModal.isOpen} isOpen={deleteModal.isOpen}
onClose={closeDeleteModal} onClose={closeDeleteModal}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCopy={handleCopyBeforeDelete}
title="Delete Confirmation" title="Delete Confirmation"
itemName={deleteModal.title} itemName={deleteModal.title}
itemType={deleteModal.type} itemType={deleteModal.type}
@@ -1275,7 +1328,7 @@ function DashboardContent() {
<MagnifyingGlass size={16} /> <MagnifyingGlass size={16} />
<input <input
type="text" type="text"
placeholder="Search dashboard..." placeholder="Search posts and pages..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="dashboard-search-input" className="dashboard-search-input"
@@ -1414,6 +1467,7 @@ function DashboardContent() {
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed}
addToast={addToast} addToast={addToast}
setActiveSection={setActiveSection}
/> />
)} )}
@@ -1424,6 +1478,7 @@ function DashboardContent() {
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed}
addToast={addToast} addToast={addToast}
setActiveSection={setActiveSection}
/> />
)} )}
@@ -1503,7 +1558,7 @@ function PostsListView({
posts: ContentItem[]; posts: ContentItem[];
onEdit: (post: ContentItem) => void; onEdit: (post: ContentItem) => void;
searchQuery: string; searchQuery: string;
onDelete: (id: string, title: string) => void; onDelete: (item: ContentItem) => void;
}) { }) {
const [filter, setFilter] = useState<"all" | "published" | "draft">("all"); const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [itemsPerPage, setItemsPerPage] = useState(15); const [itemsPerPage, setItemsPerPage] = useState(15);
@@ -1651,7 +1706,7 @@ function PostsListView({
{post.source === "dashboard" && ( {post.source === "dashboard" && (
<button <button
className="action-btn delete" className="action-btn delete"
onClick={() => onDelete(post._id, post.title)} onClick={() => onDelete(post as ContentItem)}
title="Delete" title="Delete"
> >
<Trash size={16} /> <Trash size={16} />
@@ -1698,7 +1753,7 @@ function PagesListView({
pages: ContentItem[]; pages: ContentItem[];
onEdit: (page: ContentItem) => void; onEdit: (page: ContentItem) => void;
searchQuery: string; searchQuery: string;
onDelete: (id: string, title: string) => void; onDelete: (item: ContentItem) => void;
}) { }) {
const [filter, setFilter] = useState<"all" | "published" | "draft">("all"); const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [itemsPerPage, setItemsPerPage] = useState(15); const [itemsPerPage, setItemsPerPage] = useState(15);
@@ -1844,7 +1899,7 @@ function PagesListView({
{page.source === "dashboard" && ( {page.source === "dashboard" && (
<button <button
className="action-btn delete" className="action-btn delete"
onClick={() => onDelete(page._id, page.title)} onClick={() => onDelete(page as ContentItem)}
title="Delete" title="Delete"
> >
<Trash size={16} /> <Trash size={16} />
@@ -2540,11 +2595,13 @@ function WriteSection({
sidebarCollapsed, sidebarCollapsed,
setSidebarCollapsed, setSidebarCollapsed,
addToast, addToast,
setActiveSection,
}: { }: {
contentType: "post" | "page"; contentType: "post" | "page";
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>; setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
setActiveSection: (section: DashboardSection) => void;
}) { }) {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -2742,7 +2799,7 @@ function WriteSection({
setRichTextHtml(prev => prev + `<p><img src="${src}" alt="${alt}" /></p>`); setRichTextHtml(prev => prev + `<p><img src="${src}" alt="${alt}" /></p>`);
} }
} else { } else {
// Preview mode - just append to content // Preview mode - append to content
setContent(prev => prev + "\n" + markdown); setContent(prev => prev + "\n" + markdown);
} }
}, [content, editorMode]); }, [content, editorMode]);
@@ -2833,6 +2890,9 @@ published: false
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [content, contentType]); }, [content, contentType]);
// Default slug values that should trigger a warning
const DEFAULT_SLUGS = ["your-post-url", "page-url"];
// Parse frontmatter and save to database // Parse frontmatter and save to database
const handleSaveToDb = useCallback(async () => { const handleSaveToDb = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@@ -2841,6 +2901,7 @@ published: false
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!frontmatterMatch) { if (!frontmatterMatch) {
addToast("Content must have valid frontmatter (---)", "error"); addToast("Content must have valid frontmatter (---)", "error");
setIsSaving(false);
return; return;
} }
@@ -2876,6 +2937,27 @@ published: false
if (!title || !slug) { if (!title || !slug) {
addToast("Frontmatter must include title and slug", "error"); addToast("Frontmatter must include title and slug", "error");
setIsSaving(false);
return;
}
// Check if slug is still default and warn user
if (DEFAULT_SLUGS.includes(slug)) {
addToast(
`Warning: Your slug is still "${slug}". Please change the slug to a unique URL-friendly value before saving.`,
"warning"
);
setIsSaving(false);
return;
}
// Check if title is still default
if (title === "Your Post Title" || title === "Page Title") {
addToast(
`Warning: Please change the title from "${title}" to something unique before saving.`,
"warning"
);
setIsSaving(false);
return; return;
} }
@@ -2910,7 +2992,11 @@ published: false
authorImage, authorImage,
}, },
}); });
addToast(`Post "${title}" saved to database`, "success"); addToast(`Post "${title}" saved to database. Redirecting to Posts...`, "success");
// Navigate to posts section after successful save
setTimeout(() => {
setActiveSection("posts");
}, 500);
} else { } else {
const published = parseBool("published") ?? false; const published = parseBool("published") ?? false;
const order = parseNumber("order"); const order = parseNumber("order");
@@ -2938,7 +3024,11 @@ published: false
authorImage, authorImage,
}, },
}); });
addToast(`Page "${title}" saved to database`, "success"); addToast(`Page "${title}" saved to database. Redirecting to Pages...`, "success");
// Navigate to pages section after successful save
setTimeout(() => {
setActiveSection("pages");
}, 500);
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to save"; const message = error instanceof Error ? error.message : "Failed to save";
@@ -2946,7 +3036,7 @@ published: false
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [content, contentType, createPostMutation, createPageMutation, addToast]); }, [content, contentType, createPostMutation, createPageMutation, addToast, setActiveSection]);
// Calculate stats // Calculate stats
const lines = content.split("\n").length; const lines = content.split("\n").length;
@@ -3008,7 +3098,8 @@ published: false
<button <button
onClick={() => setShowImageUpload(true)} onClick={() => setShowImageUpload(true)}
className="dashboard-action-btn" className="dashboard-action-btn"
title="Insert Image" title={editorMode === "richtext" ? "Image insertion not available in Rich Text mode" : "Insert Image"}
disabled={editorMode === "richtext"}
> >
<Image size={16} /> <Image size={16} />
<span>Image</span> <span>Image</span>
@@ -4792,6 +4883,9 @@ function ConfigSection({
// Media library // Media library
mediaEnabled: siteConfig.media?.enabled || false, mediaEnabled: siteConfig.media?.enabled || false,
mediaMaxFileSize: siteConfig.media?.maxFileSize || 10, mediaMaxFileSize: siteConfig.media?.maxFileSize || 10,
// Related posts
relatedPostsDefaultViewMode: siteConfig.relatedPosts?.defaultViewMode || "thumbnails",
relatedPostsShowViewToggle: siteConfig.relatedPosts?.showViewToggle !== false,
}); });
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -4978,6 +5072,13 @@ export const siteConfig: SiteConfig = {
maxFileSize: ${config.mediaMaxFileSize}, maxFileSize: ${config.mediaMaxFileSize},
allowedTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"], allowedTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
}, },
// Related posts configuration
// Controls the display of related posts at the bottom of blog posts
relatedPosts: {
defaultViewMode: "${config.relatedPostsDefaultViewMode}",
showViewToggle: ${config.relatedPostsShowViewToggle},
},
}; };
export default siteConfig; export default siteConfig;
@@ -5871,6 +5972,38 @@ export default siteConfig;
</p> </p>
</div> </div>
{/* Related Posts */}
<div className="dashboard-config-card">
<h3>Related Posts</h3>
<div className="config-field">
<label>Default View Mode</label>
<select
value={config.relatedPostsDefaultViewMode}
onChange={(e) =>
handleChange("relatedPostsDefaultViewMode", e.target.value)
}
>
<option value="thumbnails">Thumbnails</option>
<option value="list">List</option>
</select>
</div>
<div className="config-field checkbox">
<label>
<input
type="checkbox"
checked={config.relatedPostsShowViewToggle}
onChange={(e) =>
handleChange("relatedPostsShowViewToggle", e.target.checked)
}
/>
<span>Show view toggle button</span>
</label>
</div>
<p className="config-hint">
Controls the display of related posts at the bottom of blog posts. Thumbnails view shows image, title, description and author.
</p>
</div>
{/* Version Control */} {/* Version Control */}
<VersionControlCard addToast={addToast} /> <VersionControlCard addToast={addToast} />

View File

@@ -4,6 +4,17 @@ import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api"; import { api } from "../../convex/_generated/api";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash";
import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript";
import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript";
import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown";
import diff from "react-syntax-highlighter/dist/esm/languages/prism/diff";
import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
import { Copy, Check } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
import PostList from "../components/PostList"; import PostList from "../components/PostList";
import FeaturedCards from "../components/FeaturedCards"; import FeaturedCards from "../components/FeaturedCards";
import LogoMarquee from "../components/LogoMarquee"; import LogoMarquee from "../components/LogoMarquee";
@@ -11,8 +22,200 @@ import GitHubContributions from "../components/GitHubContributions";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import SocialFooter from "../components/SocialFooter"; import SocialFooter from "../components/SocialFooter";
import NewsletterSignup from "../components/NewsletterSignup"; import NewsletterSignup from "../components/NewsletterSignup";
import DiffCodeBlock from "../components/DiffCodeBlock";
import siteConfig from "../config/siteConfig"; import siteConfig from "../config/siteConfig";
// Sanitize schema for home intro markdown
const homeSanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
span: ["className", "class", "style"],
},
};
// Register languages for syntax highlighting
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("shell", bash);
SyntaxHighlighter.registerLanguage("sh", bash);
SyntaxHighlighter.registerLanguage("javascript", javascript);
SyntaxHighlighter.registerLanguage("js", javascript);
SyntaxHighlighter.registerLanguage("typescript", typescript);
SyntaxHighlighter.registerLanguage("ts", typescript);
SyntaxHighlighter.registerLanguage("markdown", markdown);
SyntaxHighlighter.registerLanguage("md", markdown);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("json", json);
// Cursor Dark Theme for syntax highlighting
const cursorDarkTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#e4e4e7",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "0.9em",
textAlign: "left",
whiteSpace: "pre",
wordSpacing: "normal",
wordBreak: "normal",
wordWrap: "normal",
lineHeight: "1.6",
tabSize: 2,
},
'pre[class*="language-"]': {
color: "#e4e4e7",
background: "#18181b",
padding: "1.25em",
margin: "0",
overflow: "auto",
borderRadius: "0.5rem",
},
comment: { color: "#71717a" },
punctuation: { color: "#a1a1aa" },
property: { color: "#93c5fd" },
string: { color: "#86efac" },
keyword: { color: "#c4b5fd" },
function: { color: "#fcd34d" },
number: { color: "#fdba74" },
operator: { color: "#f9a8d4" },
"class-name": { color: "#93c5fd" },
boolean: { color: "#fdba74" },
variable: { color: "#e4e4e7" },
"attr-name": { color: "#93c5fd" },
"attr-value": { color: "#86efac" },
tag: { color: "#f87171" },
deleted: { color: "#f87171", background: "rgba(248, 113, 113, 0.1)" },
inserted: { color: "#86efac", background: "rgba(134, 239, 172, 0.1)" },
};
// Cursor Light Theme for syntax highlighting
const cursorLightTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#27272a",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "0.9em",
textAlign: "left",
whiteSpace: "pre",
wordSpacing: "normal",
wordBreak: "normal",
wordWrap: "normal",
lineHeight: "1.6",
tabSize: 2,
},
'pre[class*="language-"]': {
color: "#27272a",
background: "#f4f4f5",
padding: "1.25em",
margin: "0",
overflow: "auto",
borderRadius: "0.5rem",
},
comment: { color: "#71717a" },
punctuation: { color: "#52525b" },
property: { color: "#2563eb" },
string: { color: "#16a34a" },
keyword: { color: "#7c3aed" },
function: { color: "#ca8a04" },
number: { color: "#ea580c" },
operator: { color: "#db2777" },
"class-name": { color: "#2563eb" },
boolean: { color: "#ea580c" },
variable: { color: "#27272a" },
"attr-name": { color: "#2563eb" },
"attr-value": { color: "#16a34a" },
tag: { color: "#dc2626" },
deleted: { color: "#dc2626", background: "rgba(220, 38, 38, 0.1)" },
inserted: { color: "#16a34a", background: "rgba(22, 163, 74, 0.1)" },
};
// Tan Theme for syntax highlighting
const cursorTanTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#44403c",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "0.9em",
textAlign: "left",
whiteSpace: "pre",
wordSpacing: "normal",
wordBreak: "normal",
wordWrap: "normal",
lineHeight: "1.6",
tabSize: 2,
},
'pre[class*="language-"]': {
color: "#44403c",
background: "#f5f5f0",
padding: "1.25em",
margin: "0",
overflow: "auto",
borderRadius: "0.5rem",
},
comment: { color: "#78716c" },
punctuation: { color: "#57534e" },
property: { color: "#1d4ed8" },
string: { color: "#15803d" },
keyword: { color: "#6d28d9" },
function: { color: "#a16207" },
number: { color: "#c2410c" },
operator: { color: "#be185d" },
"class-name": { color: "#1d4ed8" },
boolean: { color: "#c2410c" },
variable: { color: "#44403c" },
"attr-name": { color: "#1d4ed8" },
"attr-value": { color: "#15803d" },
tag: { color: "#b91c1c" },
deleted: { color: "#b91c1c", background: "rgba(185, 28, 28, 0.1)" },
inserted: { color: "#15803d", background: "rgba(21, 128, 61, 0.1)" },
};
// Copy button component for code blocks
function CodeCopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
className="code-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
);
}
// Inline copy button for copy-command spans
function InlineCopyButton({ command }: { command: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
className="inline-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy command"}
title={copied ? "Copied!" : "Copy command"}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
);
}
// Local storage key for view mode preference // Local storage key for view mode preference
const VIEW_MODE_KEY = "featured-view-mode"; const VIEW_MODE_KEY = "featured-view-mode";
@@ -87,6 +290,8 @@ function HeadingAnchor({ id }: { id: string }) {
} }
export default function Home() { export default function Home() {
const { theme } = useTheme();
// Fetch published posts from Convex (only if showing on home) // Fetch published posts from Convex (only if showing on home)
const posts = useQuery( const posts = useQuery(
api.posts.getAllPosts, api.posts.getAllPosts,
@@ -108,6 +313,20 @@ export default function Home() {
siteConfig.featuredViewMode, siteConfig.featuredViewMode,
); );
// Get code theme based on current theme
const getCodeTheme = () => {
switch (theme) {
case "dark":
return cursorDarkTheme;
case "light":
return cursorLightTheme;
case "tan":
return cursorTanTheme;
default:
return cursorDarkTheme;
}
};
// Load saved view mode preference from localStorage // Load saved view mode preference from localStorage
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem(VIEW_MODE_KEY); const saved = localStorage.getItem(VIEW_MODE_KEY);
@@ -189,6 +408,7 @@ export default function Home() {
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, homeSanitizeSchema]]}
components={{ components={{
// Open external links in new tab // Open external links in new tab
a: ({ href, children }) => ( a: ({ href, children }) => (
@@ -280,6 +500,90 @@ export default function Home() {
hr() { hr() {
return <hr className="blog-hr" />; return <hr className="blog-hr" />;
}, },
// Code blocks with syntax highlighting
code(codeProps) {
const { className, children, style, ...restProps } =
codeProps as {
className?: string;
children?: React.ReactNode;
style?: React.CSSProperties;
};
const match = /language-(\w+)/.exec(className || "");
// Detect inline code vs code blocks
const codeContent = String(children);
const hasNewlines = codeContent.includes("\n");
const isShort = codeContent.length < 80;
const hasLanguage = !!match || !!className;
// It's inline only if: no language, short content, no newlines
const isInline = !hasLanguage && isShort && !hasNewlines;
if (isInline) {
return (
<code className="inline-code" style={style} {...restProps}>
{children}
</code>
);
}
const codeString = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text";
// Route diff/patch to DiffCodeBlock for enhanced diff rendering
if (language === "diff" || language === "patch") {
return (
<DiffCodeBlock
code={codeString}
language={language as "diff" | "patch"}
/>
);
}
const isTextBlock = language === "text";
// Custom styles for text blocks to enable wrapping
const textBlockStyle = isTextBlock
? {
whiteSpace: "pre-wrap" as const,
wordWrap: "break-word" as const,
overflowWrap: "break-word" as const,
}
: {};
return (
<div
className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}
>
{match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} />
<SyntaxHighlighter
style={getCodeTheme()}
language={language}
PreTag="div"
customStyle={textBlockStyle}
codeTagProps={
isTextBlock ? { style: textBlockStyle } : undefined
}
>
{codeString}
</SyntaxHighlighter>
</div>
);
},
// Span component with copy-command support
span({ className, children }) {
if (className === "copy-command") {
const command = getTextContent(children);
return (
<span className="copy-command">
<code className="inline-code">{command}</code>
<InlineCopyButton command={command} />
</span>
);
}
return <span className={className}>{children}</span>;
},
}} }}
> >
{stripHtmlComments(homeIntro.content)} {stripHtmlComments(homeIntro.content)}

View File

@@ -15,9 +15,12 @@ import { useSidebar } from "../context/SidebarContext";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { ArrowLeft, Link as LinkIcon, Rss, Tag } from "lucide-react"; import { ArrowLeft, Link as LinkIcon, Rss, Tag } from "lucide-react";
import { XLogo, LinkedinLogo } from "@phosphor-icons/react"; import { XLogo, LinkedinLogo } from "@phosphor-icons/react";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import siteConfig from "../config/siteConfig"; import siteConfig from "../config/siteConfig";
// Local storage key for related posts view mode preference
const RELATED_POSTS_VIEW_MODE_KEY = "related-posts-view-mode";
// Site configuration - update these for your site (or run npm run configure) // Site configuration - update these for your site (or run npm run configure)
const SITE_URL = "https://www.markdown.fast"; const SITE_URL = "https://www.markdown.fast";
const SITE_NAME = "markdown sync framework"; const SITE_NAME = "markdown sync framework";
@@ -87,6 +90,28 @@ export default function Post({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// State for related posts view mode toggle (list or thumbnails)
const [relatedPostsViewMode, setRelatedPostsViewMode] = useState<"list" | "thumbnails">(
siteConfig.relatedPosts?.defaultViewMode ?? "thumbnails",
);
// Load saved related posts view mode preference from localStorage
useEffect(() => {
const saved = localStorage.getItem(RELATED_POSTS_VIEW_MODE_KEY);
if (saved === "list" || saved === "thumbnails") {
setRelatedPostsViewMode(saved);
}
}, []);
// Toggle related posts view mode and save preference
const toggleRelatedPostsViewMode = useCallback(() => {
setRelatedPostsViewMode((prev) => {
const newMode = prev === "list" ? "thumbnails" : "list";
localStorage.setItem(RELATED_POSTS_VIEW_MODE_KEY, newMode);
return newMode;
});
}, []);
// Scroll to hash anchor after content loads // Scroll to hash anchor after content loads
// Skip if there's a search query - let the highlighting hook handle scroll // Skip if there's a search query - let the highlighting hook handle scroll
useEffect(() => { useEffect(() => {
@@ -859,26 +884,125 @@ export default function Post({
{/* Related posts section - only shown for blog posts with shared tags */} {/* Related posts section - only shown for blog posts with shared tags */}
{relatedPosts && relatedPosts.length > 0 && ( {relatedPosts && relatedPosts.length > 0 && (
<div className="related-posts"> <div className="related-posts">
<h3 className="related-posts-title">Related Posts</h3> <div className="related-posts-header">
<ul className="related-posts-list"> <h3 className="related-posts-title">Related Posts</h3>
{relatedPosts.map((relatedPost) => ( {siteConfig.relatedPosts?.showViewToggle !== false && (
<li key={relatedPost.slug} className="related-post-item"> <button
className="view-toggle-button"
onClick={toggleRelatedPostsViewMode}
aria-label={`Switch to ${relatedPostsViewMode === "list" ? "thumbnail" : "list"} view`}
>
{relatedPostsViewMode === "thumbnails" ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
)}
</button>
)}
</div>
{/* Thumbnail view - shows image, title, description, author */}
{relatedPostsViewMode === "thumbnails" ? (
<div className="related-posts-thumbnails">
{relatedPosts.map((relatedPost) => (
<Link <Link
key={relatedPost.slug}
to={`/${relatedPost.slug}`} to={`/${relatedPost.slug}`}
className="related-post-link" className="related-post-thumbnail"
> >
<span className="related-post-title"> {relatedPost.image && (
{relatedPost.title} <div className="related-post-thumbnail-image">
</span> <img
{relatedPost.readTime && ( src={relatedPost.image}
<span className="related-post-meta"> alt={relatedPost.title}
{relatedPost.readTime} loading="lazy"
</span> />
</div>
)} )}
<div className="related-post-thumbnail-content">
<h4 className="related-post-thumbnail-title">
{relatedPost.title}
</h4>
{(relatedPost.excerpt || relatedPost.description) && (
<p className="related-post-thumbnail-excerpt">
{relatedPost.excerpt || relatedPost.description}
</p>
)}
<div className="related-post-thumbnail-meta">
{relatedPost.authorImage && (
<img
src={relatedPost.authorImage}
alt={relatedPost.authorName || "Author"}
className="related-post-thumbnail-author-image"
/>
)}
{relatedPost.authorName && (
<span className="related-post-thumbnail-author">
{relatedPost.authorName}
</span>
)}
{relatedPost.date && (
<span className="related-post-thumbnail-date">
{format(parseISO(relatedPost.date), "MMM d, yyyy")}
</span>
)}
</div>
</div>
</Link> </Link>
</li> ))}
))} </div>
</ul> ) : (
/* List view - simple list with title and read time */
<ul className="related-posts-list">
{relatedPosts.map((relatedPost) => (
<li key={relatedPost.slug} className="related-post-item">
<Link
to={`/${relatedPost.slug}`}
className="related-post-link"
>
<span className="related-post-title">
{relatedPost.title}
</span>
{relatedPost.readTime && (
<span className="related-post-meta">
{relatedPost.readTime}
</span>
)}
</Link>
</li>
))}
</ul>
)}
</div> </div>
)} )}

View File

@@ -1774,6 +1774,33 @@ body {
font-size: var(--font-size-inline-code); font-size: var(--font-size-inline-code);
} }
/* Diff fallback styles for invalid diff format */
.diff-fallback {
background: var(--code-bg);
padding: 1.25em;
margin: 0;
overflow: auto;
border-radius: 0.5rem;
font-family: var(--font-mono);
font-size: 0.9em;
line-height: 1.6;
}
.diff-fallback code {
background: none;
padding: 0;
}
.diff-added {
color: var(--color-success, #86efac);
background: rgba(134, 239, 172, 0.1);
}
.diff-removed {
color: var(--color-error, #f87171);
background: rgba(248, 113, 113, 0.1);
}
/* Copy command inline styles */ /* Copy command inline styles */
.copy-command { .copy-command {
display: inline-flex; display: inline-flex;
@@ -2180,6 +2207,128 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* Related posts header with toggle */
.related-posts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.related-posts-header .related-posts-title {
margin-bottom: 0;
}
/* Related posts thumbnail view */
.related-posts-thumbnails {
display: flex;
flex-direction: column;
gap: 24px;
}
.related-post-thumbnail {
display: flex;
gap: 16px;
text-decoration: none;
color: inherit;
transition: opacity 0.2s ease;
}
.related-post-thumbnail:hover {
opacity: 0.85;
}
.related-post-thumbnail-image {
flex-shrink: 0;
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
background-color: var(--bg-secondary);
}
.related-post-thumbnail-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.related-post-thumbnail-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.related-post-thumbnail-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.related-post-thumbnail-excerpt {
font-size: 0.9375rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.related-post-thumbnail-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: auto;
}
.related-post-thumbnail-author-image {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.related-post-thumbnail-author {
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.02em;
}
.related-post-thumbnail-date {
color: var(--text-muted);
}
/* Mobile responsive styles for related posts thumbnails */
@media (max-width: 480px) {
.related-post-thumbnail-image {
width: 100px;
height: 100px;
}
.related-post-thumbnail-title {
font-size: 1rem;
}
.related-post-thumbnail-excerpt {
font-size: 0.875rem;
-webkit-line-clamp: 2;
}
}
/* Loading and error states */ /* Loading and error states */
.loading, .loading,
.no-posts, .no-posts,
@@ -9286,6 +9435,12 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
.dashboard-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.dashboard-action-btn.primary { .dashboard-action-btn.primary {
background: var(--text-primary); background: var(--text-primary);
color: var(--bg-primary); color: var(--bg-primary);
@@ -9439,6 +9594,30 @@ body {
max-height: 100vh; max-height: 100vh;
} }
/* Hide scrollbars but keep scroll functionality for dashboard */
.dashboard-sidebar-left,
.dashboard-nav,
.dashboard-content,
.dashboard-sidebar-right,
.dashboard-frontmatter-fields {
-ms-overflow-style: none; /* IE/old Edge */
scrollbar-width: none; /* Firefox */
}
.dashboard-sidebar-left::-webkit-scrollbar,
.dashboard-nav::-webkit-scrollbar,
.dashboard-content::-webkit-scrollbar,
.dashboard-sidebar-right::-webkit-scrollbar,
.dashboard-frontmatter-fields::-webkit-scrollbar {
width: 0;
height: 0;
}
/* Prevent outer page scroll on dashboard pages */
body:has(.dashboard-layout) {
overflow: hidden;
}
.dashboard-frontmatter { .dashboard-frontmatter {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -10968,7 +11147,7 @@ body {
border-right: none; border-right: none;
} }
/* Quill Editor Styles */ /* React Quill Editor Styles */
.dashboard-quill-container { .dashboard-quill-container {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -10985,25 +11164,6 @@ body {
overflow: hidden; 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 { .dashboard-quill-container .ql-toolbar {
background: var(--bg-primary); background: var(--bg-primary);
border: none; border: none;
@@ -11024,29 +11184,146 @@ body {
} }
.dashboard-quill-container .ql-toolbar button:hover .ql-stroke, .dashboard-quill-container .ql-toolbar button:hover .ql-stroke,
.dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke { .dashboard-quill-container .ql-toolbar .ql-picker:hover .ql-stroke {
stroke: var(--text-primary); stroke: var(--text-primary);
} }
.dashboard-quill-container .ql-toolbar button:hover .ql-fill, .dashboard-quill-container .ql-toolbar button:hover .ql-fill,
.dashboard-quill-container .ql-toolbar button.ql-active .ql-fill { .dashboard-quill-container .ql-toolbar .ql-picker:hover .ql-fill {
fill: var(--text-primary); fill: var(--text-primary);
} }
.dashboard-quill-container .ql-toolbar .ql-picker-label:hover, .dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke,
.dashboard-quill-container .ql-toolbar .ql-picker-label.ql-active { .dashboard-quill-container .ql-toolbar .ql-picker.ql-active .ql-stroke {
color: var(--text-primary); stroke: var(--accent-color);
} }
.dashboard-quill-container .ql-toolbar .ql-picker-options { .dashboard-quill-container .ql-toolbar button.ql-active .ql-fill,
background: var(--bg-primary); .dashboard-quill-container .ql-toolbar .ql-picker.ql-active .ql-fill {
border-color: var(--border-color); fill: var(--accent-color);
} }
.dashboard-quill-container .ql-container { .dashboard-quill-container .ql-container {
flex: 1;
overflow: auto;
font-family: var(--font-family-base);
font-size: var(--font-size-base);
border: none; border: none;
} }
.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-editor h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 1.5rem 0 0.75rem;
line-height: 1.2;
}
.dashboard-quill-container .ql-editor h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 1.25rem 0 0.625rem;
line-height: 1.3;
}
.dashboard-quill-container .ql-editor h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 1rem 0 0.5rem;
line-height: 1.4;
}
.dashboard-quill-container .ql-editor p {
margin: 0 0 1rem;
line-height: 1.6;
}
.dashboard-quill-container .ql-editor blockquote {
border-left: 3px solid var(--border-color);
margin: 1rem 0;
padding-left: 1rem;
color: var(--text-secondary);
font-style: italic;
}
.dashboard-quill-container .ql-editor pre.ql-syntax {
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: var(--border-radius-sm);
padding: 1rem;
overflow-x: auto;
}
.dashboard-quill-container .ql-editor ul,
.dashboard-quill-container .ql-editor ol {
margin: 0.5rem 0 1rem 1.5rem;
padding: 0;
}
.dashboard-quill-container .ql-editor li {
margin: 0.25rem 0;
line-height: 1.6;
}
.dashboard-quill-container .ql-editor a {
color: var(--accent-color);
text-decoration: underline;
}
.dashboard-quill-container .ql-editor img {
max-width: 100%;
height: auto;
border-radius: var(--border-radius-sm);
margin: 1rem 0;
}
/* Quill picker dropdown styling */
.dashboard-quill-container .ql-picker-options {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
}
.dashboard-quill-container .ql-picker-item {
color: var(--text-secondary);
}
.dashboard-quill-container .ql-picker-item:hover {
color: var(--text-primary);
}
/* Quill tooltip styling */
.dashboard-quill-container .ql-tooltip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
box-shadow: var(--shadow-lg);
border-radius: var(--border-radius-sm);
}
.dashboard-quill-container .ql-tooltip input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: var(--border-radius-sm);
}
.dashboard-quill-container .ql-tooltip a.ql-action,
.dashboard-quill-container .ql-tooltip a.ql-remove {
color: var(--accent-color);
}
.dashboard-write-type-selector { .dashboard-write-type-selector {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -11720,7 +11997,8 @@ body {
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--text-primary); color: var(--text-primary);
word-break: break-all; white-space: nowrap;
overflow-x: auto;
} }
.dashboard-modal-copy-btn { .dashboard-modal-copy-btn {
@@ -11841,6 +12119,69 @@ body {
line-height: 1.5; line-height: 1.5;
} }
.dashboard-modal-copy-prompt {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
margin-top: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
}
.dashboard-modal-copy-prompt-text {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.4;
}
.dashboard-modal-copy-prompt-text svg {
flex-shrink: 0;
color: var(--accent-color);
margin-top: 0.125rem;
}
.dashboard-modal-copy-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.dashboard-modal-copy-btn:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--text-tertiary);
}
.dashboard-modal-copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dashboard-modal-copy-btn.copied {
background: #22c55e;
border-color: #22c55e;
color: #fff;
}
.dashboard-modal-copy-btn svg {
flex-shrink: 0;
}
.dashboard-modal-btn.danger { .dashboard-modal-btn.danger {
display: flex; display: flex;
align-items: center; align-items: center;