dev: add Dockerfile.dev + allow content-edits API from dev.mamadaevv.site
This commit is contained in:
1 parent
8090f0cb2f
commit
1941cec13f
2 files changed
+224
-214
No files matched your search
@@ -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"]
|
||||
+215
-214
@@ -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/**)");
|
||||
Reference in new issue
Block a user