215 lines
5.8 KiB
JavaScript
215 lines
5.8 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];
|
|
return host === "localhost" || host === "127.0.0.1";
|
|
}
|
|
|
|
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/**)");
|