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

216 lines
5.7 KiB
JavaScript

/**
* Локальный сервер: public/ + BrowserSync + API правок контента (только localhost).
*/
import browserSync from "browser-sync";
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();
browserSync.create().init({
server: {
baseDir: publicDir,
middleware: [contentEditsApi],
},
files: [path.join(publicDir, "**", "*")],
port: 4175,
ui: { port: 4176 },
notify: false,
open: false,
ghostMode: false,
});
console.log("serve-dev: http://localhost:4175 (public/, reload only public/**)");