const express = require("express"); const expressWs = require("express-ws"); const chokidar = require("chokidar"); const path = require("path"); const fs = require("fs"); const BlueprintBuilder = require("./BlueprintBuilder"); class BlueprintServer { constructor(options = {}) { this.app = express(); this.wsInstance = expressWs(this.app); this.options = { port: 3000, srcDir: "./src", outDir: "./dist", liveReload: false, minified: true, ...options, }; this.clients = new Map(); this.filesWithErrors = new Set(); this.setupServer(); if (this.options.liveReload) { const watcher = chokidar.watch([], { ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, }); setTimeout(() => { watcher.add(this.options.srcDir); this.setupWatcher(watcher); }, 1000); } } log(tag, message, color) { const colorCodes = { blue: "\x1b[34m", green: "\x1b[32m", red: "\x1b[31m", orange: "\x1b[33m", lightGray: "\x1b[90m", reset: "\x1b[0m", bgBlue: "\x1b[44m", }; console.log( `${colorCodes.bgBlue} BP ${colorCodes.reset} ${ colorCodes[color] || "" }${message}${colorCodes.reset}` ); } async buildAll() { this.log("INFO", "Building all Blueprint files...", "lightGray"); if (fs.existsSync(this.options.outDir)) { fs.rmSync(this.options.outDir, { recursive: true }); } fs.mkdirSync(this.options.outDir, { recursive: true }); const files = this.getAllFiles(this.options.srcDir); let success = true; const errors = []; const startTime = Date.now(); for (const file of files) { const relativePath = path.relative(this.options.srcDir, file); const outputPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); this.ensureDirectoryExistence(outputPath); const builder = new BlueprintBuilder({ minified: this.options.minified }); const result = builder.build(file, path.dirname(outputPath)); if (!result.success) { success = false; errors.push({ file, errors: result.errors }); } } const totalTime = Date.now() - startTime; if (success) { this.log( "SUCCESS", `All files built successfully in ${totalTime}ms!`, "green" ); } else { this.log("ERROR", "Build failed with errors:", "red"); errors.forEach(({ file, errors }) => { this.log("ERROR", `File: ${file}`, "red"); errors.forEach((err) => { this.log( "ERROR", `${err.type} at line ${err.line}, column ${err.column}: ${err.message}`, "red" ); }); }); process.exit(1); } } ensureDirectoryExistence(filePath) { const dirname = path.dirname(filePath); if (fs.existsSync(dirname)) { return true; } this.ensureDirectoryExistence(dirname); fs.mkdirSync(dirname); } getAllFiles(dirPath, arrayOfFiles) { const files = fs.readdirSync(dirPath); arrayOfFiles = arrayOfFiles || []; files.forEach((file) => { if (fs.statSync(path.join(dirPath, file)).isDirectory()) { arrayOfFiles = this.getAllFiles(path.join(dirPath, file), arrayOfFiles); } else if (file.endsWith(".bp")) { arrayOfFiles.push(path.join(dirPath, file)); } }); return arrayOfFiles; } setupServer() { this.app.use((req, res, next) => { const isHtmlRequest = req.path.endsWith(".html") || !path.extname(req.path); if (this.options.liveReload && isHtmlRequest) { const htmlPath = req.path.endsWith(".html") ? path.join(this.options.outDir, req.path) : path.join(this.options.outDir, req.path + ".html"); fs.readFile(htmlPath, "utf8", (err, data) => { if (err) return next(); let html = data; const script = ` `; html = html.replace("", script + ""); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); return res.send(html); }); } else { next(); } }); this.app.use(express.static(this.options.outDir)); this.app.get("*", (req, res, next) => { if (path.extname(req.path)) return next(); const htmlPath = path.join(this.options.outDir, req.path + ".html"); if (fs.existsSync(htmlPath)) { res.sendFile(htmlPath); } else if (req.path === "/") { const pages = fs .readdirSync(this.options.outDir) .filter((f) => f.endsWith(".html")) .map((f) => f.replace(".html", "")); res.send(` Blueprint Pages

Blueprint Pages

`); } else { next(); } }); if (this.options.liveReload) { this.app.ws("/live-reload", (ws, req) => { ws.on("message", (msg) => { try { const data = JSON.parse(msg); if (data.type === "register" && data.page) { this.clients.set(ws, data.page); } } catch (error) {} }); ws.on("close", () => { this.clients.delete(ws); }); ws.on("error", (error) => { this.log("ERROR", "WebSocket error:", "red"); this.clients.delete(ws); }); }); } } setupWatcher(watcher) { watcher.on("change", async (filepath) => { if (filepath.endsWith(".bp")) { this.log("INFO", `File ${filepath} has been changed`, "blue"); try { const builder = new BlueprintBuilder({ minified: this.options.minified, debug: this.options.debug, }); const relativePath = path.relative(this.options.srcDir, filepath); const outputPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); this.ensureDirectoryExistence(outputPath); const result = builder.build(filepath, path.dirname(outputPath)); if (result.success) { this.log("SUCCESS", "Rebuilt successfully", "green"); this.filesWithErrors.delete(filepath); const htmlFile = relativePath.replace(/\.bp$/, ".html"); const htmlPath = path.join(this.options.outDir, htmlFile); try { const newContent = fs.readFileSync(htmlPath, "utf8"); for (const [client, page] of this.clients.entries()) { if ( page === htmlFile.replace(/\\/g, "/") && client.readyState === 1 ) { try { client.send( JSON.stringify({ type: "reload", content: newContent, }) ); } catch (error) { this.log("ERROR", "Error sending content:", "red"); this.clients.delete(client); } } } } catch (error) { this.log("ERROR", "Error reading new content:", "red"); } } else { this.filesWithErrors.add(filepath); this.log("ERROR", `Build failed: ${result.errors.map(e => e.message).join(", ")}`, "red"); this.log("INFO", "Waiting for next file change...", "orange"); for (const [client, page] of this.clients.entries()) { const htmlFile = relativePath.replace(/\.bp$/, ".html"); if ( page === htmlFile.replace(/\\/g, "/") && client.readyState === 1 ) { try { client.send( JSON.stringify({ type: "buildError", errors: result.errors, }) ); } catch (error) { this.log("ERROR", "Error sending error notification:", "red"); this.clients.delete(client); } } } } } catch (error) { this.log("ERROR", "Unexpected error during build:", "red"); this.filesWithErrors.add(filepath); } } }); watcher.on("add", async (filepath) => { if (filepath.endsWith(".bp")) { this.log("INFO", `New file detected: ${filepath}`, "lightGray"); try { const builder = new BlueprintBuilder({ minified: this.options.minified, debug: this.options.debug, }); const relativePath = path.relative(this.options.srcDir, filepath); const outputPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); this.ensureDirectoryExistence(outputPath); const result = builder.build(filepath, path.dirname(outputPath)); if (result.success) { this.log("SUCCESS", "Built new file successfully", "green"); this.filesWithErrors.delete(filepath); } else { this.filesWithErrors.add(filepath); this.log("ERROR", "Build failed for new file", "red"); } } catch (error) { this.log("ERROR", "Unexpected error building new file:", "red"); this.filesWithErrors.add(filepath); } } }); watcher.on("unlink", async (filepath) => { if (filepath.endsWith(".bp")) { this.log("INFO", `File ${filepath} removed`, "orange"); const relativePath = path.relative(this.options.srcDir, filepath); const htmlPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".html") ); const cssPath = path.join( this.options.outDir, relativePath.replace(/\.bp$/, ".css") ); if (fs.existsSync(htmlPath)) { fs.unlinkSync(htmlPath); } if (fs.existsSync(cssPath)) { fs.unlinkSync(cssPath); } const dirPath = path.dirname(htmlPath); if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) { fs.rmdirSync(dirPath); } } }); } async start() { await this.buildAll(); this.app.listen(this.options.port, () => { this.log( "INFO", `Blueprint dev server running at http://localhost:${this.options.port}`, "green" ); this.log( "INFO", `Mode: ${this.options.minified ? "Minified" : "Human Readable"}`, "lightGray" ); if (this.options.liveReload) { this.log( "INFO", "Live reload enabled - watching for changes...", "lightGray" ); } }); } } module.exports = BlueprintServer;