import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { exec } from "node:child_process"; import { fileURLToPath } from "node:url"; const MIME_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".xml": "application/xml; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".txt": "text/plain; charset=utf-8" }; /** * Normalizes Obsidian-specific syntax (wikilinks, internal attachments) * into standard web URLs and image references. * @param {string} content Raw Markdown content from Obsidian * @returns {string} Normalized Markdown content */ export function normalizeObsidianMarkdown(content) { if (typeof content !== "string") return ""; let normalized = content; // Bridge created -> date so Eleventy collections sorting and filters work seamlessly const createdMatch = normalized.match(/^created:\s*([^\r\n]+)$/m); const hasDate = /^date:\s*.+$/m.test(normalized); if (createdMatch && !hasDate) { normalized = normalized.replace(/^(---\r?\n)/, `$1date: ${createdMatch[1].trim()}\n`); } // Bridge layout -> layouts/post.njk so Obsidian notes render with the blog theme and CSS const hasLayout = /^layout:\s*.+$/m.test(normalized); if (!hasLayout) { normalized = normalized.replace(/^(---\r?\n)/, "$1layout: layouts/post.njk\n"); } // 1. Convert internal attachments: ![[image.png|alt]] or ![[image.png]] normalized = normalized.replace(/!\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (match, filename, alt) => { const cleanFilename = path.basename(filename.trim()); const altText = alt ? alt.trim() : cleanFilename; return `![${altText}](/assets/images/posts/${cleanFilename})`; }); // 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/) normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => { const slug = slugify(target); return `[${label.trim()}](/posts/${slug}/)`; }); // 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/) normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => { const trimmed = target.trim(); const slug = slugify(trimmed); return `[${trimmed}](/posts/${slug}/)`; }); return normalized; } function slugify(text) { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, "") .replace(/[\s_-]+/g, "-") .replace(/^-+|-+$/g, ""); } let isBuilding = false; let buildQueued = false; /** * Rebuilds the static site using Eleventy. * Uses a build queue to prevent concurrent build races. * @returns {Promise} */ export function triggerEleventyBuild() { if (isBuilding) { buildQueued = true; return Promise.resolve(); } const root = process.cwd(); const hasConfig = fs.existsSync(path.join(root, "eleventy.config.js")) || fs.existsSync(path.join(root, ".eleventy.js")) || fs.existsSync(path.join(root, "node_modules/@11ty/eleventy")); if (!hasConfig) { return Promise.resolve(); } isBuilding = true; return new Promise((resolve) => { exec("npx @11ty/eleventy", (err) => { isBuilding = false; if (err) { console.error("[Ingestion API] Eleventy build error:", err); } else { console.log("[Ingestion API] Eleventy build completed successfully."); } if (buildQueued) { buildQueued = false; triggerEleventyBuild().then(resolve); } else { resolve(); } }); }); } /** * Creates the HTTP server instance for serving static files and handling ingestion API * @param {string} [customSiteDir] Optional custom directory for _site * @param {string} [customPostsDir] Optional custom directory for posts * @param {string} [customAssetsDir] Optional custom directory for uploaded post assets * @returns {http.Server} */ export function createServer(customSiteDir, customPostsDir, customAssetsDir) { const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site")); const postsDir = path.resolve(customPostsDir || path.join(process.cwd(), "src/posts")); const assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts")); const isCustomEnv = Boolean(customSiteDir || customPostsDir || customAssetsDir); // Ensure default directory data file exists for layout inheritance try { if (!fs.existsSync(postsDir)) { fs.mkdirSync(postsDir, { recursive: true }); } const postsJsonPath = path.join(postsDir, "posts.json"); if (!fs.existsSync(postsJsonPath)) { fs.writeFileSync(postsJsonPath, JSON.stringify({ layout: "layouts/post.njk" }, null, 2)); } } catch (err) { console.warn("[Server] Could not initialize posts.json directory data file:", err.message); } return http.createServer(async (req, res) => { // Standard security headers res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); const parsedUrl = new URL(req.url, "http://localhost"); // Health check endpoint for container orchestrators & telemetry if (parsedUrl.pathname === "/healthz" || parsedUrl.pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ status: "ok", uptime: process.uptime() })); return; } // Obsidian Ingestion API: /api/posts if (parsedUrl.pathname === "/api/posts") { const authHeader = req.headers["authorization"] || ""; const expectedSecret = process.env.API_SECRET; if (!expectedSecret) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Server API_SECRET is not configured" })); return; } const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match || match[1] !== expectedSecret) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Unauthorized: Invalid or missing Bearer token" })); return; } if (req.method === "POST") { let body = ""; req.on("data", (chunk) => { body += chunk; if (body.length > 50 * 1024 * 1024) { res.writeHead(413, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Payload too large (50MB limit)" })); req.destroy(); } }); req.on("end", async () => { let payload; try { payload = JSON.parse(body); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid JSON payload" })); return; } const { slug, content, assets } = payload; if (!slug || typeof slug !== "string" || !content || typeof content !== "string") { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing required fields: 'slug' and 'content'" })); return; } const cleanSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, ""); if (!cleanSlug) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid slug format" })); return; } const hasTitle = /^title:\s*.+$/m.test(content); const hasDate = /^date:\s*.+$/m.test(content) || /^created:\s*.+$/m.test(content); if (!hasTitle || !hasDate) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing required frontmatter: 'title' and 'created' (or 'date') are required" })); return; } try { fs.mkdirSync(postsDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true }); const normalizedContent = normalizeObsidianMarkdown(content); const targetFilePath = path.join(postsDir, `${cleanSlug}.md`); fs.writeFileSync(targetFilePath, normalizedContent, "utf-8"); if (Array.isArray(assets)) { for (const asset of assets) { if (asset && asset.filename && asset.data) { const cleanName = path.basename(asset.filename).replace(/[^a-zA-Z0-9._-]/g, ""); if (cleanName) { const assetPath = path.join(assetsDir, cleanName); fs.writeFileSync(assetPath, Buffer.from(asset.data, "base64")); } } } } // Rebuild static site with Eleventy if in standard environment if (!isCustomEnv) { await triggerEleventyBuild(); } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "published", slug: cleanSlug, path: `/posts/${cleanSlug}/` })); } catch (writeErr) { console.error("[Ingestion API] Error saving post/assets:", writeErr); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `Failed to write file to disk: ${writeErr.message}` })); } }); return; } if (req.method === "DELETE") { const querySlug = parsedUrl.searchParams.get("slug"); if (!querySlug) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing 'slug' parameter" })); return; } const cleanSlug = querySlug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, ""); if (!cleanSlug) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid slug parameter" })); return; } // 1. Remove source markdown post const targetFilePath = path.join(postsDir, `${cleanSlug}.md`); if (fs.existsSync(targetFilePath)) { fs.unlinkSync(targetFilePath); } // 2. Remove compiled static site directory and file const compiledDir = path.join(siteDir, "posts", cleanSlug); if (fs.existsSync(compiledDir)) { fs.rmSync(compiledDir, { recursive: true, force: true }); } const compiledHtml = path.join(siteDir, "posts", `${cleanSlug}.html`); if (fs.existsSync(compiledHtml)) { fs.unlinkSync(compiledHtml); } // 3. Rebuild static collections (index.html, dispatches, tags) with Eleventy if (!isCustomEnv) { await triggerEleventyBuild(); } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "deleted", slug: cleanSlug })); return; } res.writeHead(405, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Method Not Allowed" })); return; } // Path normalization & security validation against directory traversal let decodedPath = ""; try { decodedPath = decodeURIComponent(parsedUrl.pathname); } catch { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Bad Request"); return; } const resolvedPath = path.resolve(siteDir, `.${decodedPath}`); // Boundary check: ensure target path is within siteDir if (!resolvedPath.startsWith(siteDir)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden"); return; } let targetFile = resolvedPath; // Directory index resolution if (fs.existsSync(targetFile) && fs.statSync(targetFile).isDirectory()) { targetFile = path.join(targetFile, "index.html"); } // Clean URL extensionless fallback (/posts/my-post -> /posts/my-post.html) if (!fs.existsSync(targetFile) && fs.existsSync(`${targetFile}.html`)) { targetFile = `${targetFile}.html`; } // Check if target file exists and is a regular file if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) { const ext = path.extname(targetFile).toLowerCase(); const contentType = MIME_TYPES[ext] || "application/octet-stream"; // Cache control: assets get long-term caching, html gets short if (decodedPath.startsWith("/assets/")) { res.setHeader("Cache-Control", "public, max-age=2592000, immutable"); } else { res.setHeader("Cache-Control", "public, max-age=0, must-revalidate"); } res.writeHead(200, { "Content-Type": contentType }); const stream = fs.createReadStream(targetFile); stream.pipe(res); return; } // 404 Not Found fallback const custom404 = path.join(siteDir, "404.html"); if (fs.existsSync(custom404)) { res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); fs.createReadStream(custom404).pipe(res); } else { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("404 Not Found"); } }); } // Direct CLI execution handler const currentFilePath = fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { const port = parseInt(process.env.PORT || "8080", 10); const host = process.env.HOST || "0.0.0.0"; const siteDir = path.resolve(process.cwd(), "_site"); if (!fs.existsSync(siteDir)) { console.warn(`[Static Server] Warning: '${siteDir}' does not exist yet. Run 'npm run build' first.`); } const server = createServer(); server.listen(port, host, () => { console.log(`[Static Server] Serving '${siteDir}' on http://${host}:${port}`); }); // Graceful termination handling const shutdown = (signal) => { console.log(`\n[Static Server] Received ${signal}. Shutting down gracefully...`); server.close(() => { process.exit(0); }); }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); }