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