@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
JavaScript
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;
}