Files
site-portfolio/scripts/serve-dev.mjs

255 lines
7.1 KiB
JavaScript

/**
* Локальный сервер: 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", "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/)");
});