diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..433a4ed --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,9 @@ +# Dev environment: serves public/ with BrowserSync + content-edits API (port 4175) +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build:pages +EXPOSE 4175 +CMD ["npm", "run", "serve"] diff --git a/scripts/serve-dev.mjs b/scripts/serve-dev.mjs index d38db5d..242b5b8 100644 --- a/scripts/serve-dev.mjs +++ b/scripts/serve-dev.mjs @@ -1,214 +1,215 @@ -/** - * Локальный сервер: 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/**)"); +/** + * Локальный сервер: 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"]; + 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/**)");