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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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