/** * Локальный сервер: public/ + API правок контента (только localhost). * Фолбэк: нативный http без browser-sync (portscanner глючит на Node 23+). */ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.join(__dirname, ".."); const publicDir = path.join(root, "public"); const editsDir = path.join(root, "texts", "content-edits"); const PENDING_FILE = path.join(editsDir, "pending.json"); const PAGE_RE = /^[\w./-]+\.html$/; function ensureEditsDir() { fs.mkdirSync(editsDir, { recursive: true }); } function isLocalRequest(req) { const host = (req.headers.host || "").split(":")[0]; const ALLOWED = ["localhost", "127.0.0.1", "10.200.201.3", "dev.mamadaevv.site", "f2ogw6fzi6trnp7cp43x8wsd.31.129.103.242.sslip.io"]; return ALLOWED.includes(host); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } function sendJson(res, status, data) { res.statusCode = status; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(data)); } function emptyPending() { return { version: 2, updatedAt: null, items: [] }; } function readPending() { if (!fs.existsSync(PENDING_FILE)) return emptyPending(); try { const data = JSON.parse(fs.readFileSync(PENDING_FILE, "utf8")); if (!Array.isArray(data.items)) data.items = []; data.version = 2; return data; } catch { return emptyPending(); } } function writePending(data) { data.version = 2; data.updatedAt = new Date().toISOString(); data.items = data.items.map((item, index) => ({ ...item, n: index + 1, })); fs.writeFileSync(PENDING_FILE, JSON.stringify(data, null, 2) + "\n", "utf8"); return data; } function legacyFileToPage(filename) { const base = filename.replace(/\.json$/i, ""); return `${base.replace(/--/g, "/")}.html`; } function migrateLegacyFiles() { if (!fs.existsSync(editsDir)) return; const pending = readPending(); let changed = false; for (const name of fs.readdirSync(editsDir)) { if (name === "pending.json" || name === "README.md" || name === ".gitkeep") continue; if (!name.endsWith(".json")) continue; const legacyPath = path.join(editsDir, name); let legacy; try { legacy = JSON.parse(fs.readFileSync(legacyPath, "utf8")); } catch { continue; } const page = legacy.page || legacyFileToPage(name); const legacyItems = Array.isArray(legacy.items) ? legacy.items : []; for (const item of legacyItems) { if (!(item.instruction || "").trim()) continue; pending.items.push({ ...item, page }); changed = true; } fs.unlinkSync(legacyPath); changed = true; } if (changed) writePending(pending); } function itemsForPage(pending, page) { return pending.items .filter((item) => item.page === page && (item.instruction || "").trim()) .map((item, index) => ({ ...item, n: index + 1 })); } function mergePageItems(page, pageItems) { const pending = readPending(); const trimmed = pageItems .filter((item) => (item.instruction || "").trim()) .map((item) => ({ ...item, page })); const others = pending.items.filter((item) => item.page !== page); pending.items = [...others, ...trimmed]; return writePending(pending); } function contentEditsApi(req, res, next) { const url = new URL(req.url || "/", "http://localhost"); if (!url.pathname.startsWith("/api/content-edits")) { next(); return; } if (!isLocalRequest(req)) { sendJson(res, 403, { error: "Forbidden" }); return; } ensureEditsDir(); migrateLegacyFiles(); const page = url.searchParams.get("page") || ""; if (req.method === "GET") { const pending = readPending(); if (!page) { sendJson(res, 200, pending); return; } if (!PAGE_RE.test(page)) { sendJson(res, 400, { error: "Invalid page" }); return; } sendJson(res, 200, { version: 2, page, updatedAt: pending.updatedAt, items: itemsForPage(pending, page), }); return; } if (req.method === "PUT") { readBody(req) .then((body) => { let payload; try { payload = JSON.parse(body); } catch { sendJson(res, 400, { error: "Invalid JSON" }); return; } const payloadPage = payload.page || page; if (!PAGE_RE.test(payloadPage)) { sendJson(res, 400, { error: "Invalid page" }); return; } if (page && payloadPage !== page) { sendJson(res, 400, { error: "Page mismatch" }); return; } if (!Array.isArray(payload.items)) { sendJson(res, 400, { error: "items must be array" }); return; } const pending = mergePageItems(payloadPage, payload.items); sendJson(res, 200, { version: 2, page: payloadPage, updatedAt: pending.updatedAt, items: itemsForPage(pending, payloadPage), }); }) .catch(() => sendJson(res, 500, { error: "Write failed" })); return; } sendJson(res, 405, { error: "Method not allowed" }); } ensureEditsDir(); if (!fs.existsSync(PENDING_FILE)) { writePending(emptyPending()); } migrateLegacyFiles(); const MIME = { ".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", ".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", ".mp4": "video/mp4", ".webm": "video/webm", }; function serveStatic(req, res) { const url = new URL(req.url || "/", "http://localhost"); let filePath = path.join(publicDir, decodeURIComponent(url.pathname)); if (filePath.endsWith(path.sep)) filePath = path.join(filePath, "index.html"); fs.stat(filePath, (err, stat) => { if (err || !stat.isFile()) { const fallback = path.join(publicDir, "index.html"); fs.readFile(fallback, (e2, data) => { if (e2) { res.writeHead(404); res.end("Not found"); return; } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(data); }); return; } const ext = path.extname(filePath).toLowerCase(); const mime = MIME[ext] || "application/octet-stream"; fs.readFile(filePath, (e2, data) => { if (e2) { res.writeHead(500); res.end("Error"); return; } res.writeHead(200, { "Content-Type": mime }); res.end(data); }); }); } const server = http.createServer((req, res) => { contentEditsApi(req, res, () => serveStatic(req, res)); }); server.listen(4175, () => { console.log("serve-dev: http://localhost:4175 (public/)"); });