mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
new: npx create-markdown-sync CLI , ui , related post thumbnails features
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,6 +31,10 @@ dist-ssr
|
||||
# Fork configuration (user-specific)
|
||||
fork-config.json
|
||||
|
||||
# CLI package build artifacts
|
||||
packages/*/dist/
|
||||
packages/*/node_modules/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
26
TASK.md
26
TASK.md
@@ -4,10 +4,34 @@
|
||||
|
||||
## Current Status
|
||||
|
||||
v2.18.1 ready. README cleanup with docs links.
|
||||
v2.19.0 ready. npx create-markdown-sync CLI.
|
||||
|
||||
## 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] Reduced from 609 lines to 155 lines
|
||||
- [x] Added Documentation section with links to markdown.fast/docs
|
||||
|
||||
46
changelog.md
46
changelog.md
@@ -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/).
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "OpenCode Integration"
|
||||
slug: "docs-opencode"
|
||||
description: "This framework includes full OpenCode support with agents, commands, skills, and plugins."
|
||||
date: "2026-01-10"
|
||||
published: true
|
||||
tags: ["opencode", "plugins", "terminal"]
|
||||
@@ -8,7 +9,8 @@ readTime: "4 min read"
|
||||
order: 2
|
||||
showInNav: false
|
||||
layout: "sidebar"
|
||||
featuredOrder: 2
|
||||
featuredOrder: 6
|
||||
featured: true
|
||||
blogFeatured: true
|
||||
rightSidebar: true
|
||||
showImageAtTop: false
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -91,7 +119,7 @@ Updating public/.well-known/ai-plugin.json...
|
||||
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:
|
||||
|
||||
@@ -342,11 +370,12 @@ If you want to clear the sample content, delete the markdown files in those dire
|
||||
|
||||
## 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`
|
||||
2. **Manual**: Follow `FORK_CONFIG.md` step-by-step or paste the AI prompt into Claude/ChatGPT
|
||||
1. **npx CLI (Recommended)**: `npx create-markdown-sync my-site` - interactive wizard creates and configures everything
|
||||
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.
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Import external articles as markdown posts using Firecrawl. Get yo
|
||||
date: "2025-12-26"
|
||||
slug: "how-to-use-firecrawl"
|
||||
published: true
|
||||
featured: true
|
||||
featured: false
|
||||
featuredOrder: 6
|
||||
image: /images/firecrwall-blog.png
|
||||
tags: ["tutorial", "firecrawl", "import"]
|
||||
|
||||
@@ -11,6 +11,65 @@ docsSectionOrder: 4
|
||||
|
||||
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
|
||||
|
||||
@@ -7,9 +7,9 @@ order: -1
|
||||
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
|
||||
Your content is instantly available to browsers, LLMs, and AI
|
||||
@@ -34,3 +34,7 @@ agents. -->
|
||||
**Semantic search** - Find content by meaning, not just keywords.
|
||||
|
||||
**Ask AI** - Chat with your site content. Get answers with sources.
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -773,6 +773,10 @@ export const getRelatedPosts = query({
|
||||
date: v.string(),
|
||||
tags: v.array(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(),
|
||||
}),
|
||||
),
|
||||
@@ -804,6 +808,10 @@ export const getRelatedPosts = query({
|
||||
date: post.date,
|
||||
tags: post.tags,
|
||||
readTime: post.readTime,
|
||||
image: post.image,
|
||||
excerpt: post.excerpt,
|
||||
authorName: post.authorName,
|
||||
authorImage: post.authorImage,
|
||||
sharedTags,
|
||||
};
|
||||
})
|
||||
|
||||
28
files.md
28
files.md
@@ -35,7 +35,7 @@ A brief description of each file in the codebase.
|
||||
|
||||
| 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/`)
|
||||
|
||||
@@ -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:"). |
|
||||
| `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). |
|
||||
| `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 |
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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 |
|
||||
| `search.ts` | Full text search queries across posts and pages |
|
||||
| `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) |
|
||||
| `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/`)
|
||||
|
||||
| File | Description |
|
||||
|
||||
842
package-lock.json
generated
842
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:convex": "convex dev",
|
||||
|
||||
66
packages/create-markdown-sync/README.md
Normal file
66
packages/create-markdown-sync/README.md
Normal 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
|
||||
54
packages/create-markdown-sync/package.json
Normal file
54
packages/create-markdown-sync/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
76
packages/create-markdown-sync/src/clone.ts
Normal file
76
packages/create-markdown-sync/src/clone.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
302
packages/create-markdown-sync/src/configure.ts
Normal file
302
packages/create-markdown-sync/src/configure.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
107
packages/create-markdown-sync/src/convex-setup.ts
Normal file
107
packages/create-markdown-sync/src/convex-setup.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/create-markdown-sync/src/index.ts
Normal file
117
packages/create-markdown-sync/src/index.ts
Normal 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();
|
||||
92
packages/create-markdown-sync/src/install.ts
Normal file
92
packages/create-markdown-sync/src/install.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
133
packages/create-markdown-sync/src/utils.ts
Normal file
133
packages/create-markdown-sync/src/utils.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
604
packages/create-markdown-sync/src/wizard.ts
Normal file
604
packages/create-markdown-sync/src/wizard.ts
Normal 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;
|
||||
}
|
||||
20
packages/create-markdown-sync/tsconfig.json
Normal file
20
packages/create-markdown-sync/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -2,11 +2,93 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
Released January 10, 2026
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Ask AI
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Content
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Dashboard
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Frontmatter
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
Set up image uploads for the dashboard using ConvexFS and Bunny.net CDN.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# OpenCode Integration
|
||||
|
||||
> This framework includes full OpenCode support with agents, commands, skills, and plugins.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2026-01-10
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Keyword Search
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Semantic Search
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,7 +109,7 @@ Updating public/.well-known/ai-plugin.json...
|
||||
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:
|
||||
|
||||
@@ -332,11 +360,12 @@ If you want to clear the sample content, delete the markdown files in those dire
|
||||
|
||||
## 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`
|
||||
2. **Manual**: Follow `FORK_CONFIG.md` step-by-step or paste the AI prompt into Claude/ChatGPT
|
||||
1. **npx CLI (Recommended)**: `npx create-markdown-sync my-site` - interactive wizard creates and configures everything
|
||||
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.
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
---
|
||||
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
|
||||
Your content is instantly available to browsers, LLMs, and AI
|
||||
@@ -31,4 +31,8 @@ agents. -->
|
||||
|
||||
**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.
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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
|
||||
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.
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **[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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-10
|
||||
Date: 2026-01-11
|
||||
---
|
||||
|
||||
# Newsletter Demo Page
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -29,6 +29,19 @@ import { fileURLToPath } from "url";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
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
|
||||
interface ForkConfig {
|
||||
siteName: string;
|
||||
@@ -112,10 +125,10 @@ function readConfig(): ForkConfig {
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error("Error: fork-config.json not found.");
|
||||
console.log("\nTo get started:");
|
||||
console.log("1. Copy fork-config.json.example to fork-config.json");
|
||||
console.log("2. Edit fork-config.json with your site information");
|
||||
console.log("3. Run npm run configure again");
|
||||
log("\nTo get started:");
|
||||
log("1. Copy fork-config.json.example to fork-config.json");
|
||||
log("2. Edit fork-config.json with your site information");
|
||||
log("3. Run npm run configure again");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -131,7 +144,7 @@ function updateFile(
|
||||
const filePath = path.join(PROJECT_ROOT, relativePath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`Warning: ${relativePath} not found, skipping.`);
|
||||
warn(`Warning: ${relativePath} not found, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,29 +161,37 @@ function updateFile(
|
||||
|
||||
if (modified) {
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
console.log(` Updated: ${relativePath}`);
|
||||
log(` Updated: ${relativePath}`);
|
||||
} else {
|
||||
console.log(` No changes: ${relativePath}`);
|
||||
log(` No changes: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update siteConfig.ts
|
||||
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");
|
||||
let content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// Update site name
|
||||
// Update site name (match single-quoted or double-quoted strings properly)
|
||||
content = content.replace(
|
||||
/name: ['"].*?['"]/,
|
||||
`name: '${config.siteName}'`,
|
||||
/name: '(?:[^'\\]|\\.)*'/,
|
||||
`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(
|
||||
/title: ['"].*?['"]/,
|
||||
`title: "${config.siteTitle}"`,
|
||||
/title: '(?:[^'\\]|\\.)*'/,
|
||||
`title: "${config.siteTitle.replace(/"/g, '\\"')}"`,
|
||||
);
|
||||
content = content.replace(
|
||||
/title: "(?:[^"\\]|\\.)*"/,
|
||||
`title: "${config.siteTitle.replace(/"/g, '\\"')}"`,
|
||||
);
|
||||
|
||||
// Update bio
|
||||
@@ -389,12 +410,12 @@ function updateSiteConfig(config: ForkConfig): void {
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
console.log(` Updated: src/config/siteConfig.ts`);
|
||||
log(` Updated: src/config/siteConfig.ts`);
|
||||
}
|
||||
|
||||
// Update Home.tsx
|
||||
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}`;
|
||||
|
||||
@@ -439,7 +460,7 @@ function updateHomeTsx(config: ForkConfig): void {
|
||||
|
||||
// Update Post.tsx
|
||||
function updatePostTsx(config: ForkConfig): void {
|
||||
console.log("\nUpdating src/pages/Post.tsx...");
|
||||
log("\nUpdating src/pages/Post.tsx...");
|
||||
|
||||
updateFile("src/pages/Post.tsx", [
|
||||
// Match any existing SITE_URL value (https://...)
|
||||
@@ -457,7 +478,7 @@ function updatePostTsx(config: ForkConfig): void {
|
||||
|
||||
// Update DocsPage.tsx
|
||||
function updateDocsPageTsx(config: ForkConfig): void {
|
||||
console.log("\nUpdating src/pages/DocsPage.tsx...");
|
||||
log("\nUpdating src/pages/DocsPage.tsx...");
|
||||
|
||||
updateFile("src/pages/DocsPage.tsx", [
|
||||
// Match any existing SITE_URL value (https://...)
|
||||
@@ -470,7 +491,7 @@ function updateDocsPageTsx(config: ForkConfig): void {
|
||||
|
||||
// Update convex/http.ts
|
||||
function updateConvexHttp(config: ForkConfig): void {
|
||||
console.log("\nUpdating convex/http.ts...");
|
||||
log("\nUpdating convex/http.ts...");
|
||||
|
||||
updateFile("convex/http.ts", [
|
||||
// Match any existing SITE_URL value with process.env fallback
|
||||
@@ -503,7 +524,7 @@ function updateConvexHttp(config: ForkConfig): void {
|
||||
|
||||
// Update convex/rss.ts
|
||||
function updateConvexRss(config: ForkConfig): void {
|
||||
console.log("\nUpdating convex/rss.ts...");
|
||||
log("\nUpdating convex/rss.ts...");
|
||||
|
||||
updateFile("convex/rss.ts", [
|
||||
// Match any existing SITE_URL value with process.env fallback
|
||||
@@ -526,7 +547,7 @@ function updateConvexRss(config: ForkConfig): void {
|
||||
|
||||
// Update index.html
|
||||
function updateIndexHtml(config: ForkConfig): void {
|
||||
console.log("\nUpdating index.html...");
|
||||
log("\nUpdating index.html...");
|
||||
|
||||
const replacements: Array<{ search: string | RegExp; replace: string }> = [
|
||||
// Meta description (match any content)
|
||||
@@ -626,7 +647,7 @@ function updateIndexHtml(config: ForkConfig): void {
|
||||
|
||||
// Update public/llms.txt
|
||||
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}`;
|
||||
|
||||
@@ -713,12 +734,12 @@ Each post contains:
|
||||
|
||||
const filePath = path.join(PROJECT_ROOT, "public/llms.txt");
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
console.log(` Updated: public/llms.txt`);
|
||||
log(` Updated: public/llms.txt`);
|
||||
}
|
||||
|
||||
// Update public/robots.txt
|
||||
function updateRobotsTxt(config: ForkConfig): void {
|
||||
console.log("\nUpdating public/robots.txt...");
|
||||
log("\nUpdating public/robots.txt...");
|
||||
|
||||
const content = `# robots.txt for ${config.siteName}
|
||||
# https://www.robotstxt.org/
|
||||
@@ -757,12 +778,12 @@ Crawl-delay: 1
|
||||
|
||||
const filePath = path.join(PROJECT_ROOT, "public/robots.txt");
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
console.log(` Updated: public/robots.txt`);
|
||||
log(` Updated: public/robots.txt`);
|
||||
}
|
||||
|
||||
// Update public/openapi.yaml
|
||||
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}`;
|
||||
// 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
|
||||
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, "");
|
||||
|
||||
@@ -838,14 +859,14 @@ function updateAiPluginJson(config: ForkConfig): void {
|
||||
|
||||
const filePath = path.join(PROJECT_ROOT, "public/.well-known/ai-plugin.json");
|
||||
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
|
||||
function updateThemeConfig(config: ForkConfig): void {
|
||||
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", [
|
||||
{
|
||||
@@ -857,7 +878,7 @@ function updateThemeConfig(config: ForkConfig): void {
|
||||
|
||||
// Update netlify/edge-functions/mcp.ts
|
||||
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", [
|
||||
// Match any existing SITE_URL constant
|
||||
@@ -880,7 +901,7 @@ function updateMcpEdgeFunction(config: ForkConfig): void {
|
||||
|
||||
// Update scripts/send-newsletter.ts
|
||||
function updateSendNewsletter(config: ForkConfig): void {
|
||||
console.log("\nUpdating scripts/send-newsletter.ts...");
|
||||
log("\nUpdating scripts/send-newsletter.ts...");
|
||||
|
||||
updateFile("scripts/send-newsletter.ts", [
|
||||
// Match any existing SITE_URL fallback in comment
|
||||
@@ -898,13 +919,13 @@ function updateSendNewsletter(config: ForkConfig): void {
|
||||
|
||||
// Main function
|
||||
function main(): void {
|
||||
console.log("Fork Configuration Script");
|
||||
console.log("=========================\n");
|
||||
log("Fork Configuration Script");
|
||||
log("=========================\n");
|
||||
|
||||
// Read configuration
|
||||
const config = readConfig();
|
||||
console.log(`Configuring site: ${config.siteName}`);
|
||||
console.log(`URL: ${config.siteUrl}`);
|
||||
log(`Configuring site: ${config.siteName}`);
|
||||
log(`URL: ${config.siteUrl}`);
|
||||
|
||||
// Apply updates to all files
|
||||
updateSiteConfig(config);
|
||||
@@ -922,14 +943,14 @@ function main(): void {
|
||||
updateMcpEdgeFunction(config);
|
||||
updateSendNewsletter(config);
|
||||
|
||||
console.log("\n=========================");
|
||||
console.log("Configuration complete!");
|
||||
console.log("\nNext steps:");
|
||||
console.log("1. Review the changes with: git diff");
|
||||
console.log("2. Run: npx convex dev (if not already running)");
|
||||
console.log("3. Run: npm run sync (to sync content to development)");
|
||||
console.log("4. Run: npm run dev (to start the dev server)");
|
||||
console.log("5. Deploy to Netlify when ready");
|
||||
log("\n=========================");
|
||||
log("Configuration complete!");
|
||||
log("\nNext steps:");
|
||||
log("1. Review the changes with: git diff");
|
||||
log("2. Run: npx convex dev (if not already running)");
|
||||
log("3. Run: npm run sync (to sync content to development)");
|
||||
log("4. Run: npm run dev (to start the dev server)");
|
||||
log("5. Deploy to Netlify when ready");
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
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({
|
||||
src,
|
||||
alt,
|
||||
@@ -165,7 +166,8 @@ function ImageLightbox({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
// Use portal to render at document body level, escaping contain: layout
|
||||
return createPortal(
|
||||
<div className="image-lightbox-backdrop" onClick={handleBackdropClick}>
|
||||
<button
|
||||
className="image-lightbox-close"
|
||||
@@ -178,7 +180,8 @@ function ImageLightbox({
|
||||
<img src={src} alt={alt} className="image-lightbox-image" />
|
||||
{alt && <div className="image-lightbox-caption">{alt}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,21 @@ const THEME_MAP: Record<string, "dark" | "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 {
|
||||
code: string;
|
||||
language: "diff" | "patch";
|
||||
@@ -30,6 +45,38 @@ export default function DiffCodeBlock({ code, language }: DiffCodeBlockProps) {
|
||||
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 (
|
||||
<div className="diff-block-wrapper" data-theme-type={themeType}>
|
||||
<div className="diff-block-header">
|
||||
|
||||
@@ -1,10 +1,179 @@
|
||||
import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
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 DiffCodeBlock from "./DiffCodeBlock";
|
||||
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)
|
||||
// style attribute is sanitized by rehypeSanitize to remove dangerous CSS
|
||||
const footerSanitizeSchema = {
|
||||
@@ -25,8 +194,23 @@ interface FooterProps {
|
||||
}
|
||||
|
||||
export default function Footer({ content }: FooterProps) {
|
||||
const { theme } = useTheme();
|
||||
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
|
||||
if (!footer.enabled) {
|
||||
return null;
|
||||
@@ -81,6 +265,77 @@ export default function Footer({ content }: FooterProps) {
|
||||
</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}
|
||||
|
||||
@@ -272,6 +272,13 @@ export interface AskAIConfig {
|
||||
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
|
||||
export interface SocialLink {
|
||||
platform:
|
||||
@@ -413,13 +420,16 @@ export interface SiteConfig {
|
||||
|
||||
// Ask AI configuration (optional)
|
||||
askAI?: AskAIConfig;
|
||||
|
||||
// Related posts configuration (optional)
|
||||
relatedPosts?: RelatedPostsConfig;
|
||||
}
|
||||
|
||||
// Default site configuration
|
||||
// Customize this for your site
|
||||
export const siteConfig: SiteConfig = {
|
||||
// Basic site info
|
||||
name: 'markdown "sync" framework',
|
||||
name: "markdown sync",
|
||||
title: "markdown sync framework",
|
||||
// Optional logo/header image (place in public/images/, set to null to hide)
|
||||
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
|
||||
|
||||
@@ -276,6 +276,7 @@ interface ConfirmDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onCopy: () => void;
|
||||
title: string;
|
||||
itemName: string;
|
||||
itemType: "post" | "page";
|
||||
@@ -286,11 +287,20 @@ function ConfirmDeleteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCopy,
|
||||
title,
|
||||
itemName,
|
||||
itemType,
|
||||
isDeleting,
|
||||
}: ConfirmDeleteModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await onCopy();
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && !isDeleting) {
|
||||
onClose();
|
||||
@@ -309,6 +319,13 @@ function ConfirmDeleteModal({
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, [isOpen, onClose, isDeleting]);
|
||||
|
||||
// Reset copied state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCopied(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -340,6 +357,30 @@ function ConfirmDeleteModal({
|
||||
This action cannot be undone. The {itemType} will be permanently
|
||||
removed from the database.
|
||||
</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 className="dashboard-modal-footer">
|
||||
@@ -670,7 +711,8 @@ function DashboardContent() {
|
||||
id: string;
|
||||
title: string;
|
||||
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);
|
||||
|
||||
// Sync server state
|
||||
@@ -862,12 +904,13 @@ function DashboardContent() {
|
||||
|
||||
// Show delete confirmation modal for a post
|
||||
const handleDeletePost = useCallback(
|
||||
(id: string, title: string) => {
|
||||
(item: ContentItem) => {
|
||||
setDeleteModal({
|
||||
isOpen: true,
|
||||
id,
|
||||
title,
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
type: "post",
|
||||
item,
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -875,12 +918,13 @@ function DashboardContent() {
|
||||
|
||||
// Show delete confirmation modal for a page
|
||||
const handleDeletePage = useCallback(
|
||||
(id: string, title: string) => {
|
||||
(item: ContentItem) => {
|
||||
setDeleteModal({
|
||||
isOpen: true,
|
||||
id,
|
||||
title,
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
type: "page",
|
||||
item,
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -889,7 +933,7 @@ function DashboardContent() {
|
||||
// Close delete modal
|
||||
const closeDeleteModal = useCallback(() => {
|
||||
if (!isDeleting) {
|
||||
setDeleteModal({ isOpen: false, id: "", title: "", type: "post" });
|
||||
setDeleteModal({ isOpen: false, id: "", title: "", type: "post", item: null });
|
||||
}
|
||||
}, [isDeleting]);
|
||||
|
||||
@@ -904,7 +948,7 @@ function DashboardContent() {
|
||||
await deletePageMutation({ id: deleteModal.id as Id<"pages"> });
|
||||
addToast("Page deleted successfully", "success");
|
||||
}
|
||||
setDeleteModal({ isOpen: false, id: "", title: "", type: "post" });
|
||||
setDeleteModal({ isOpen: false, id: "", title: "", type: "post", item: null });
|
||||
} catch (error) {
|
||||
addToast(
|
||||
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
|
||||
const handleDownloadMarkdown = useCallback(() => {
|
||||
if (!editingItem) return;
|
||||
@@ -1166,6 +1218,7 @@ function DashboardContent() {
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={closeDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
onCopy={handleCopyBeforeDelete}
|
||||
title="Delete Confirmation"
|
||||
itemName={deleteModal.title}
|
||||
itemType={deleteModal.type}
|
||||
@@ -1275,7 +1328,7 @@ function DashboardContent() {
|
||||
<MagnifyingGlass size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search dashboard..."
|
||||
placeholder="Search posts and pages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="dashboard-search-input"
|
||||
@@ -1414,6 +1467,7 @@ function DashboardContent() {
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
setSidebarCollapsed={setSidebarCollapsed}
|
||||
addToast={addToast}
|
||||
setActiveSection={setActiveSection}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1424,6 +1478,7 @@ function DashboardContent() {
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
setSidebarCollapsed={setSidebarCollapsed}
|
||||
addToast={addToast}
|
||||
setActiveSection={setActiveSection}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1503,7 +1558,7 @@ function PostsListView({
|
||||
posts: ContentItem[];
|
||||
onEdit: (post: ContentItem) => void;
|
||||
searchQuery: string;
|
||||
onDelete: (id: string, title: string) => void;
|
||||
onDelete: (item: ContentItem) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
|
||||
const [itemsPerPage, setItemsPerPage] = useState(15);
|
||||
@@ -1651,7 +1706,7 @@ function PostsListView({
|
||||
{post.source === "dashboard" && (
|
||||
<button
|
||||
className="action-btn delete"
|
||||
onClick={() => onDelete(post._id, post.title)}
|
||||
onClick={() => onDelete(post as ContentItem)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={16} />
|
||||
@@ -1698,7 +1753,7 @@ function PagesListView({
|
||||
pages: ContentItem[];
|
||||
onEdit: (page: ContentItem) => void;
|
||||
searchQuery: string;
|
||||
onDelete: (id: string, title: string) => void;
|
||||
onDelete: (item: ContentItem) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
|
||||
const [itemsPerPage, setItemsPerPage] = useState(15);
|
||||
@@ -1844,7 +1899,7 @@ function PagesListView({
|
||||
{page.source === "dashboard" && (
|
||||
<button
|
||||
className="action-btn delete"
|
||||
onClick={() => onDelete(page._id, page.title)}
|
||||
onClick={() => onDelete(page as ContentItem)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={16} />
|
||||
@@ -2540,11 +2595,13 @@ function WriteSection({
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
addToast,
|
||||
setActiveSection,
|
||||
}: {
|
||||
contentType: "post" | "page";
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
setActiveSection: (section: DashboardSection) => void;
|
||||
}) {
|
||||
const [content, setContent] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -2742,7 +2799,7 @@ function WriteSection({
|
||||
setRichTextHtml(prev => prev + `<p><img src="${src}" alt="${alt}" /></p>`);
|
||||
}
|
||||
} else {
|
||||
// Preview mode - just append to content
|
||||
// Preview mode - append to content
|
||||
setContent(prev => prev + "\n" + markdown);
|
||||
}
|
||||
}, [content, editorMode]);
|
||||
@@ -2833,6 +2890,9 @@ published: false
|
||||
URL.revokeObjectURL(url);
|
||||
}, [content, contentType]);
|
||||
|
||||
// Default slug values that should trigger a warning
|
||||
const DEFAULT_SLUGS = ["your-post-url", "page-url"];
|
||||
|
||||
// Parse frontmatter and save to database
|
||||
const handleSaveToDb = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -2841,6 +2901,7 @@ published: false
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!frontmatterMatch) {
|
||||
addToast("Content must have valid frontmatter (---)", "error");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2876,6 +2937,27 @@ published: false
|
||||
|
||||
if (!title || !slug) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2910,7 +2992,11 @@ published: false
|
||||
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 {
|
||||
const published = parseBool("published") ?? false;
|
||||
const order = parseNumber("order");
|
||||
@@ -2938,7 +3024,11 @@ published: false
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save";
|
||||
@@ -2946,7 +3036,7 @@ published: false
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [content, contentType, createPostMutation, createPageMutation, addToast]);
|
||||
}, [content, contentType, createPostMutation, createPageMutation, addToast, setActiveSection]);
|
||||
|
||||
// Calculate stats
|
||||
const lines = content.split("\n").length;
|
||||
@@ -3008,7 +3098,8 @@ published: false
|
||||
<button
|
||||
onClick={() => setShowImageUpload(true)}
|
||||
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} />
|
||||
<span>Image</span>
|
||||
@@ -4792,6 +4883,9 @@ function ConfigSection({
|
||||
// Media library
|
||||
mediaEnabled: siteConfig.media?.enabled || false,
|
||||
mediaMaxFileSize: siteConfig.media?.maxFileSize || 10,
|
||||
// Related posts
|
||||
relatedPostsDefaultViewMode: siteConfig.relatedPosts?.defaultViewMode || "thumbnails",
|
||||
relatedPostsShowViewToggle: siteConfig.relatedPosts?.showViewToggle !== false,
|
||||
});
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -4978,6 +5072,13 @@ export const siteConfig: SiteConfig = {
|
||||
maxFileSize: ${config.mediaMaxFileSize},
|
||||
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;
|
||||
@@ -5871,6 +5972,38 @@ export default siteConfig;
|
||||
</p>
|
||||
</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 */}
|
||||
<VersionControlCard addToast={addToast} />
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@ import { useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
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 FeaturedCards from "../components/FeaturedCards";
|
||||
import LogoMarquee from "../components/LogoMarquee";
|
||||
@@ -11,8 +22,200 @@ import GitHubContributions from "../components/GitHubContributions";
|
||||
import Footer from "../components/Footer";
|
||||
import SocialFooter from "../components/SocialFooter";
|
||||
import NewsletterSignup from "../components/NewsletterSignup";
|
||||
import DiffCodeBlock from "../components/DiffCodeBlock";
|
||||
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
|
||||
const VIEW_MODE_KEY = "featured-view-mode";
|
||||
|
||||
@@ -87,6 +290,8 @@ function HeadingAnchor({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Fetch published posts from Convex (only if showing on home)
|
||||
const posts = useQuery(
|
||||
api.posts.getAllPosts,
|
||||
@@ -108,6 +313,20 @@ export default function Home() {
|
||||
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
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(VIEW_MODE_KEY);
|
||||
@@ -189,6 +408,7 @@ export default function Home() {
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, homeSanitizeSchema]]}
|
||||
components={{
|
||||
// Open external links in new tab
|
||||
a: ({ href, children }) => (
|
||||
@@ -280,6 +500,90 @@ export default function Home() {
|
||||
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)}
|
||||
|
||||
@@ -15,9 +15,12 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { ArrowLeft, Link as LinkIcon, Rss, Tag } from "lucide-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";
|
||||
|
||||
// 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)
|
||||
const SITE_URL = "https://www.markdown.fast";
|
||||
const SITE_NAME = "markdown sync framework";
|
||||
@@ -87,6 +90,28 @@ export default function Post({
|
||||
|
||||
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
|
||||
// Skip if there's a search query - let the highlighting hook handle scroll
|
||||
useEffect(() => {
|
||||
@@ -859,26 +884,125 @@ export default function Post({
|
||||
{/* Related posts section - only shown for blog posts with shared tags */}
|
||||
{relatedPosts && relatedPosts.length > 0 && (
|
||||
<div className="related-posts">
|
||||
<h3 className="related-posts-title">Related Posts</h3>
|
||||
<ul className="related-posts-list">
|
||||
{relatedPosts.map((relatedPost) => (
|
||||
<li key={relatedPost.slug} className="related-post-item">
|
||||
<div className="related-posts-header">
|
||||
<h3 className="related-posts-title">Related Posts</h3>
|
||||
{siteConfig.relatedPosts?.showViewToggle !== false && (
|
||||
<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
|
||||
key={relatedPost.slug}
|
||||
to={`/${relatedPost.slug}`}
|
||||
className="related-post-link"
|
||||
className="related-post-thumbnail"
|
||||
>
|
||||
<span className="related-post-title">
|
||||
{relatedPost.title}
|
||||
</span>
|
||||
{relatedPost.readTime && (
|
||||
<span className="related-post-meta">
|
||||
{relatedPost.readTime}
|
||||
</span>
|
||||
{relatedPost.image && (
|
||||
<div className="related-post-thumbnail-image">
|
||||
<img
|
||||
src={relatedPost.image}
|
||||
alt={relatedPost.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1774,6 +1774,33 @@ body {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
@@ -2180,6 +2207,128 @@ body {
|
||||
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,
|
||||
.no-posts,
|
||||
@@ -9286,6 +9435,12 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-action-btn.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
@@ -9439,6 +9594,30 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -10968,7 +11147,7 @@ body {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Quill Editor Styles */
|
||||
/* React Quill Editor Styles */
|
||||
.dashboard-quill-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -10985,25 +11164,6 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-editor {
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-editor.ql-blank::before {
|
||||
color: var(--text-tertiary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar {
|
||||
background: var(--bg-primary);
|
||||
border: none;
|
||||
@@ -11024,29 +11184,146 @@ body {
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker-label:hover,
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker-label.ql-active {
|
||||
color: var(--text-primary);
|
||||
.dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke,
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker.ql-active .ql-stroke {
|
||||
stroke: var(--accent-color);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker-options {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border-color);
|
||||
.dashboard-quill-container .ql-toolbar button.ql-active .ql-fill,
|
||||
.dashboard-quill-container .ql-toolbar .ql-picker.ql-active .ql-fill {
|
||||
fill: var(--accent-color);
|
||||
}
|
||||
|
||||
.dashboard-quill-container .ql-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -11720,7 +11997,8 @@ body {
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dashboard-modal-copy-btn {
|
||||
@@ -11841,6 +12119,69 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user