UNPKG

@writely/preview

Version:

Lightning-fast development server with live preview for Writely blogs. Hot reload, file watching, and instant feedback for the best development experience.

493 lines (492 loc) 18.1 kB
import { processMDXContent } from "@writely/mdx"; import { watch } from "chokidar"; import express from "express"; import { existsSync, readFileSync } from "fs"; import fs from "fs-extra"; import { createServer } from "http"; import createNextServer from "next"; import open from "open"; import path from "path"; import { Server as SocketServer } from "socket.io"; // Enhanced error handling utility function handlePreviewError(error, context) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`❌ ${context}: ${errorMessage}`); // Provide helpful suggestions based on error type if (errorMessage.includes("EADDRINUSE")) { console.log("💡 Try using a different port with --port option"); } else if (errorMessage.includes("permission")) { console.log("💡 Try running with elevated permissions or check file permissions"); } else if (errorMessage.includes("ENOENT")) { console.log("💡 Check if the directory exists"); } } // Cross-platform path utility function normalizePath(filePath) { return path.normalize(filePath).replace(/\\/g, "/"); } // Detect blog structure and find all MDX files async function detectBlogStructure() { const cwd = process.cwd(); // Find all MDX files (excluding node_modules, .git, etc.) const { glob } = await import("glob"); const allMdxFiles = await glob("**/*.mdx", { ignore: [ "node_modules/**", ".git/**", "dist/**", ".next/**", "public/**", // Don't process MDX files in public folder ], cwd, }); // Check for public folder and favicon const hasPublicFolder = existsSync(path.join(cwd, "public")); const hasFavicon = existsSync(path.join(cwd, "favicon.ico")) || existsSync(path.join(cwd, "public", "favicon.ico")); return { mdxFiles: allMdxFiles, hasPublicFolder, hasFavicon, }; } export class PreviewServer { options; mdxFiles = []; nextApp; expressApp; httpServer; io; watcher = null; blogStructure = null; constructor(options) { this.options = { port: 3000, host: "localhost", open: true, ...options, }; // Initialize Express app this.expressApp = express(); // Initialize Next.js app this.nextApp = createNextServer({ dev: true, dir: process.cwd(), }); // Create HTTP server this.httpServer = createServer(this.expressApp); // Initialize Socket.IO for live reload this.io = new SocketServer(this.httpServer); } async start() { try { console.log("🚀 Starting Writely preview server..."); // 1. Detect blog structure await this.detectStructure(); // 2. Prebuild step - process all MDX files await this.prebuild(); // 3. Set up file watching with advanced listener this.setupFileWatching(); // 4. Set up Express middleware this.setupExpressMiddleware(); // 5. Start Next.js await this.nextApp.prepare(); // 6. Start server await this.startServer(); // 7. Open browser if (this.options.open) { await this.openBrowser(); } console.log(`✅ Writely preview server running at http://${this.options.host}:${this.options.port}`); console.log("📝 Edit your MDX files to see live updates"); console.log(`📁 Found ${this.blogStructure?.mdxFiles.length || 0} MDX files`); } catch (error) { handlePreviewError(error, "Failed to start preview server"); throw error; } } async detectStructure() { console.log("🔍 Detecting blog structure..."); this.blogStructure = await detectBlogStructure(); if (this.blogStructure.mdxFiles.length === 0) { console.warn("⚠️ No MDX files found in the current directory"); } if (this.blogStructure.hasPublicFolder) { console.log("✅ Public folder detected"); } if (this.blogStructure.hasFavicon) { console.log("✅ Favicon detected"); } } async prebuild() { console.log("📦 Prebuilding MDX files..."); // Clear any existing processed files this.mdxFiles = []; if (!this.blogStructure) { throw new Error("Blog structure not detected"); } // Load and process all MDX files this.mdxFiles = await this.loadMDXFiles(); console.log(`✅ Processed ${this.mdxFiles.length} MDX files`); } setupFileWatching() { console.log("👀 Setting up file watching..."); // Watch all MDX files and blog.json const watchPatterns = ["**/*.mdx", "blog.json", "favicon.ico"]; this.watcher = watch(watchPatterns, { ignored: [ "node_modules/**", ".git/**", "dist/**", ".next/**", "public/**", // Don't watch public folder for MDX changes ], persistent: true, ignoreInitial: true, }); this.watcher .on("add", (filePath) => this.handleFileAdd(filePath)) .on("change", (filePath) => this.handleFileChange(filePath)) .on("unlink", (filePath) => this.handleFileDelete(filePath)) .on("error", (error) => { console.warn("⚠️ File watching error:", error.message); }); console.log("✅ File watching active"); } setupExpressMiddleware() { // Serve favicon.ico from root this.expressApp.get("/favicon.ico", (req, res) => { const faviconPath = path.join(process.cwd(), "favicon.ico"); if (existsSync(faviconPath)) { res.sendFile(faviconPath); } else { res.status(404).end(); } }); // Serve static files from public directory const publicDir = path.join(process.cwd(), "public"); if (existsSync(publicDir)) { this.expressApp.use("/", express.static(publicDir)); } // Handle all other requests with Next.js this.expressApp.all("*", (req, res) => { // Inject config into request req.config = this.options.config; this.nextApp.getRequestHandler()(req, res); }); } async startServer() { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Server startup timeout")); }, 30000); // 30 second timeout this.httpServer.listen(this.options.port, this.options.host, () => { clearTimeout(timeout); resolve(); }); this.httpServer.on("error", (error) => { clearTimeout(timeout); if (error.code === "EADDRINUSE") { reject(new Error(`Port ${this.options.port} is already in use. Try a different port.`)); } else { reject(error); } }); }); } async openBrowser() { try { const url = `http://${this.options.host}:${this.options.port}`; await open(url); console.log(`🌐 Opened browser at ${url}`); } catch (error) { console.warn("⚠️ Failed to open browser automatically"); console.log(`💡 Open manually: http://${this.options.host}:${this.options.port}`); } } async loadMDXFiles() { try { if (!this.blogStructure) { throw new Error("Blog structure not detected"); } const mdxFiles = []; for (const file of this.blogStructure.mdxFiles) { try { const fullPath = path.join(process.cwd(), file); const content = readFileSync(fullPath, "utf-8"); const { frontmatter } = await processMDXContent(content, {}); mdxFiles.push({ content, path: file, frontmatter, }); } catch (error) { console.warn(`⚠️ Failed to process ${file}: ${error instanceof Error ? error.message : "Unknown error"}`); } } return mdxFiles; } catch (error) { console.warn("⚠️ No MDX files found in directory"); return []; } } async handleFileAdd(filePath) { try { // Skip if it's not an MDX file or blog.json if (!filePath.endsWith(".mdx") && filePath !== "blog.json") { return; } if (filePath === "blog.json") { console.log("📝 Blog configuration updated"); this.triggerLiveReload(); return; } const fullPath = path.join(process.cwd(), filePath); const content = readFileSync(fullPath, "utf-8"); const { frontmatter } = await processMDXContent(content, {}); const file = { content, path: filePath, frontmatter, }; this.mdxFiles.push(file); this.triggerLiveReload(); console.log(`➕ Added: ${filePath}`); } catch (error) { console.error(`❌ Failed to process added file ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`); } } async handleFileChange(filePath) { try { // Skip if it's not an MDX file or blog.json if (!filePath.endsWith(".mdx") && filePath !== "blog.json") { return; } if (filePath === "blog.json") { console.log("📝 Blog configuration updated"); this.triggerLiveReload(); return; } const fullPath = path.join(process.cwd(), filePath); const content = readFileSync(fullPath, "utf-8"); const { frontmatter } = await processMDXContent(content, {}); const file = { content, path: filePath, frontmatter, }; const index = this.mdxFiles.findIndex((f) => f.path === filePath); if (index >= 0) { this.mdxFiles[index] = file; } else { this.mdxFiles.push(file); } this.triggerLiveReload(); console.log(`✏️ Updated: ${filePath}`); } catch (error) { console.error(`❌ Failed to process changed file ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`); } } handleFileDelete(filePath) { // Skip if it's not an MDX file or blog.json if (!filePath.endsWith(".mdx") && filePath !== "blog.json") { return; } if (filePath === "blog.json") { console.log("📝 Blog configuration removed"); this.triggerLiveReload(); return; } this.mdxFiles = this.mdxFiles.filter((file) => file.path !== filePath); this.triggerLiveReload(); console.log(`🗑️ Deleted: ${filePath}`); } triggerLiveReload() { try { // Emit reload event to all connected clients this.io.emit("reload"); console.log("🔄 Live reload triggered"); } catch (error) { console.warn("⚠️ Failed to trigger live reload:", error instanceof Error ? error.message : "Unknown error"); } } async stop() { console.log("🛑 Stopping preview server..."); try { if (this.watcher) { this.watcher.close(); console.log("✅ File watcher stopped"); } if (this.httpServer) { await new Promise((resolve) => { this.httpServer.close(() => { resolve(); }); }); console.log("✅ HTTP server stopped"); } console.log("✅ Preview server stopped"); } catch (error) { console.warn("⚠️ Error stopping server:", error instanceof Error ? error.message : "Unknown error"); } } // Getter for accessing processed MDX files get files() { return this.mdxFiles; } } export class StaticSiteGenerator { config; mdxFiles; outputDir; constructor(config, mdxFiles, outputDir) { this.config = config; this.mdxFiles = mdxFiles; this.outputDir = outputDir; } async generate() { console.log("🚀 Generating static site..."); // 1. Create output directory await fs.ensureDir(this.outputDir); // 2. Generate static pages await this.generatePages(); // 3. Copy static assets await this.copyStaticAssets(); // 4. Generate sitemap await this.generateSitemap(); // 5. Generate RSS feed await this.generateRSS(); // 6. Generate build manifest await this.generateBuildManifest(); console.log("✅ Static site generated successfully!"); } async generatePages() { const { serialize } = await import("@writely/mdx/server"); for (const file of this.mdxFiles) { try { // Process MDX with our advanced system const result = await serialize({ source: file.content, syntaxHighlightingOptions: { theme: "github-light-default", themes: { light: "github-light-default", dark: "github-dark-default", }, }, }); // Generate HTML with theme const html = await this.generatePageHTML(file, result); // Write to output directory const outputPath = path.join(this.outputDir, this.getOutputPath(file.path)); await fs.ensureDir(path.dirname(outputPath)); await fs.writeFile(outputPath, html); } catch (error) { console.warn(`⚠️ Failed to generate page for ${file.path}:`, error); } } } async generatePageHTML(file, mdxResult) { // This would need to be implemented with proper HTML generation // For now, return a basic HTML structure return ` <!DOCTYPE html> <html lang="${this.config.language || "en"}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${file.frontmatter.title || this.config.title}</title> <meta name="description" content="${file.frontmatter.description || this.config.description}"> <link rel="stylesheet" href="/styles.css"> </head> <body> <main> <h1>${file.frontmatter.title}</h1> <div class="content"> ${mdxResult.compiledSource} </div> </main> </body> </html>`; } getOutputPath(filePath) { // Convert MDX file paths to HTML paths if (filePath.endsWith("index.mdx")) { return filePath.replace("index.mdx", "index.html"); } return filePath.replace(".mdx", ".html"); } async copyStaticAssets() { const publicDir = path.join(process.cwd(), "public"); if (await fs.pathExists(publicDir)) { await fs.copy(publicDir, path.join(this.outputDir, "public")); } } async generateSitemap() { const sitemap = `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${this.mdxFiles .map((file) => ` <url> <loc>${this.config.url}/${this.getOutputPath(file.path).replace(".html", "")}</loc> <lastmod>${file.frontmatter.date || new Date().toISOString()}</lastmod> <changefreq>weekly</changefreq> <priority>0.8</priority> </url>`) .join("")} </urlset>`; await fs.writeFile(path.join(this.outputDir, "sitemap.xml"), sitemap); } async generateRSS() { const rss = `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0"> <channel> <title>${this.config.title}</title> <description>${this.config.description}</description> <link>${this.config.url}</link> ${this.mdxFiles .map((file) => ` <item> <title>${file.frontmatter.title}</title> <description>${file.frontmatter.description || ""}</description> <link>${this.config.url}/${this.getOutputPath(file.path).replace(".html", "")}</link> <pubDate>${file.frontmatter.date || new Date().toISOString()}</pubDate> </item>`) .join("")} </channel> </rss>`; await fs.writeFile(path.join(this.outputDir, "rss.xml"), rss); } async generateBuildManifest() { const manifest = { config: this.config, files: this.mdxFiles.map((f) => ({ path: f.path, frontmatter: f.frontmatter, outputPath: this.getOutputPath(f.path), })), buildTime: new Date().toISOString(), version: "0.1.0", }; await fs.writeJSON(path.join(this.outputDir, "build-manifest.json"), manifest, { spaces: 2 }); } } export async function startPreview(options) { const server = new PreviewServer(options); await server.start(); return server; }