coingimp's picture
Bind service to Space host
8731b7c verified
Raw
History Blame Contribute Delete
189 kB
import http from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import sharp from "sharp";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputsDir = path.join(__dirname, "outputs");
const publicDir = path.join(__dirname, "public");
const buildRoot = path.join(__dirname, "build");
const port = Number(process.env.PORT || 8765);
const host = process.env.HOST || "0.0.0.0";
const HTML_TIMEOUT_MS = 20000;
const ASSET_TIMEOUT_MS = 12000;
const MAX_ASSETS = 250;
const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-flash";
const EDGE_PATH = process.env.EDGE_PATH || "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe";
const APP_USERNAME = process.env.APP_USERNAME || "admin";
const APP_PASSWORD = process.env.APP_PASSWORD || "";
const AI_RATE_LIMIT_PER_HOUR = Math.max(1, Number(process.env.AI_RATE_LIMIT_PER_HOUR || 20));
const mimeTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".zip": "application/zip"
};
let progressState = {
active: false,
buildId: null,
updatedAt: null,
logs: [],
summary: null,
awaitingApproval: false,
preview: null,
files: []
};
const previewSessions = new Map();
const approvalWaiters = new Map();
const assetDownloadPromises = new Map();
const aiRateLimits = new Map();
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (!authorizeRequest(req, res)) return;
if (req.method === "GET" && url.pathname === "/api/status") {
return sendJson(res, { ok: true, port, outputsDir });
}
if (req.method === "GET" && url.pathname === "/api/progress") {
return sendJson(res, { ok: true, ...progressState });
}
if (req.method === "POST" && url.pathname === "/api/continue") {
const body = await readJson(req);
const waiter = approvalWaiters.get(body.buildId);
if (!waiter) return sendJson(res, { ok: false, error: "No build is waiting for approval." }, 404);
approvalWaiters.delete(body.buildId);
progressState.awaitingApproval = false;
progressState.preview = null;
progressState.updatedAt = new Date().toISOString();
waiter();
return sendJson(res, { ok: true });
}
if (req.method === "POST" && url.pathname === "/api/build") {
if (progressState.active) {
return sendJson(res, { ok: false, error: "A build is already running or waiting for preview approval.", buildId: progressState.buildId }, 409);
}
const body = await readJson(req);
try {
const result = await buildProject(body);
return sendJson(res, result);
} catch (error) {
progressState.active = false;
progressState.awaitingApproval = false;
progressState.updatedAt = new Date().toISOString();
progressState.error = error.message;
throw error;
}
}
if (req.method === "GET" && url.pathname.startsWith("/download/")) {
const name = path.basename(decodeURIComponent(url.pathname.replace("/download/", "")));
const file = path.join(outputsDir, name);
if (!file.startsWith(outputsDir)) return notFound(res);
return sendFile(res, file, true);
}
if (req.method === "GET" && url.pathname.startsWith("/preview/")) {
return sendPreviewFile(res, url.pathname);
}
// === API РЕДАКТОРА СТРАНИЦ ===
// GET /api/pages?buildId=... — список страниц для редактора
if (req.method === "GET" && url.pathname === "/api/pages") {
const buildId = url.searchParams.get("buildId");
const session = previewSessions.get(buildId);
if (!session) return sendJson(res, { ok: false, error: "Build session not found" }, 404);
// БЕРЁМ из session.staticPages (доступно сразу после скачивания)
const staticPages = session.staticPages || [];
const pages = staticPages.map((p) => ({
title: p.title,
path: p.path,
file: p.file,
url: `/preview/${buildId}/pages/${encodeURIComponent(p.file)}`
}));
return sendJson(res, { ok: true, buildId, pages });
}
// GET /api/page?buildId=...&file=... — получить HTML страницы для редактора
if (req.method === "GET" && url.pathname === "/api/page") {
const buildId = url.searchParams.get("buildId");
const file = url.searchParams.get("file");
const session = previewSessions.get(buildId);
if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400);
const pageInfo = (session.staticPages || []).find((p) => p.file === file);
const filePath = path.join(session.staticDir, path.basename(file));
if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400);
try {
let html = await fs.readFile(filePath, "utf8");
const parts = splitPageHtml(html, pageInfo?.contentSelector);
let content = parts.contentHtml || html;
content = content.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`);
return sendJson(res, {
ok: true,
file,
content,
protectedLayout: true,
contentSelector: pageInfo?.contentSelector || parts.contentSelector
});
} catch {
return sendJson(res, { ok: false, error: "File not found" }, 404);
}
}
// POST /api/page — сохранить отредактированный HTML
if (req.method === "POST" && url.pathname === "/api/page") {
const body = await readJson(req);
const { buildId, file, content } = body;
const session = previewSessions.get(buildId);
if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400);
let savedContent = unwrapGutenbergHtmlBlock(String(content || ""));
savedContent = savedContent.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}");
const validationError = validateEditableHtmlFragment(savedContent);
if (validationError) return sendJson(res, { ok: false, error: validationError }, 400);
const pageInfo = (session.staticPages || []).find((p) => p.file === file);
if (!pageInfo) return sendJson(res, { ok: false, error: "Page metadata not found" }, 404);
const filePath = path.join(session.staticDir, path.basename(file));
if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400);
let fullHtml;
try {
fullHtml = await fs.readFile(filePath, "utf8");
} catch {
return sendJson(res, { ok: false, error: "Static HTML file not found" }, 404);
}
const replacement = replaceEditableContentRegion(fullHtml, pageInfo.contentSelector, savedContent);
if (!replacement.ok) return sendJson(res, { ok: false, error: replacement.error }, 400);
await fs.writeFile(filePath, replacement.html, "utf8");
const gutenbergContent = htmlToGutenberg(savedContent);
if (session.staticPages) {
const page = session.staticPages.find((p) => p.file === file);
if (page) page.gutenbergContent = gutenbergContent;
}
try {
await updateBuildManifest(buildId, (manifest) => {
const page = manifest.staticPages.find((p) => p.file === file);
if (page) page.gutenbergContent = gutenbergContent;
});
} catch {}
return sendJson(res, { ok: true, protectedLayout: true, previewUrl: `/preview/${buildId}/pages/${encodeURIComponent(file)}?v=${Date.now()}` });
}
if (req.method === "GET" && url.pathname === "/api/footer") {
const buildId = url.searchParams.get("buildId");
const session = previewSessions.get(buildId);
const firstPage = session?.staticPages?.[0];
if (!session || !firstPage) return sendJson(res, { ok: false, error: "Build session not found" }, 404);
try {
const filePath = path.join(session.staticDir, path.basename(firstPage.file));
const html = await fs.readFile(filePath, "utf8");
const footer = extractEditableFooterContent(html);
if (!footer.ok) return sendJson(res, { ok: false, error: footer.error }, 400);
const content = footer.content.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`);
return sendJson(res, { ok: true, content });
} catch {
return sendJson(res, { ok: false, error: "Footer source page not found" }, 404);
}
}
if (req.method === "POST" && url.pathname === "/api/footer") {
const body = await readJson(req);
const { buildId, content } = body;
const session = previewSessions.get(buildId);
if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build session not found" }, 404);
let savedContent = unwrapGutenbergHtmlBlock(String(content || ""));
savedContent = savedContent.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}");
const validationError = validateEditableHtmlFragment(savedContent);
if (validationError) return sendJson(res, { ok: false, error: validationError }, 400);
const targets = [
...(session.staticPages || []).map((page) => path.join(session.staticDir, path.basename(page.file))),
...(session.casinoPreviewPages || []).map((page) => path.join(session.casinoPreviewDir, path.basename(page.file)))
];
try {
for (const filePath of targets) {
const html = await fs.readFile(filePath, "utf8");
const replaced = replaceEditableFooterRegion(html, savedContent);
if (!replaced.ok) throw new Error(replaced.error);
await fs.writeFile(filePath, replaced.html, "utf8");
}
session.footerHtml = savedContent;
return sendJson(res, { ok: true, previewVersion: Date.now() });
} catch (error) {
return sendJson(res, { ok: false, error: error.message }, 400);
}
}
if (req.method === "GET" && url.pathname === "/api/ai/status") {
const buildId = url.searchParams.get("buildId");
const session = buildId ? previewSessions.get(buildId) : null;
return sendJson(res, {
ok: true,
configured: Boolean(process.env.DEEPSEEK_API_KEY),
model: DEEPSEEK_MODEL,
available: Boolean(session?.staticPages?.length),
canUndo: Boolean(session?.ai?.undoStack?.length)
});
}
if (req.method === "POST" && url.pathname === "/api/ai/chat") {
const rateLimit = consumeAiRateLimit(req);
if (!rateLimit.ok) {
res.setHeader("Retry-After", String(rateLimit.retryAfter));
return sendJson(res, { ok: false, error: `AI request limit reached. Try again in ${Math.ceil(rateLimit.retryAfter / 60)} minutes.` }, 429);
}
const body = await readJson(req);
const session = previewSessions.get(body.buildId);
if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409);
if (!process.env.DEEPSEEK_API_KEY) return sendJson(res, { ok: false, error: "DEEPSEEK_API_KEY is not configured." }, 503);
const message = String(body.message || "").trim();
if (!message) return sendJson(res, { ok: false, error: "Write a request for AI Review." }, 400);
const result = await createAiReviewProposal(body.buildId, session, message, body.activePath);
return sendJson(res, { ok: true, ...result });
}
if (req.method === "POST" && url.pathname === "/api/ai/apply") {
const body = await readJson(req);
const session = previewSessions.get(body.buildId);
if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409);
const result = await applyAiProposal(body.buildId, session, String(body.proposalId || ""));
return sendJson(res, { ok: true, ...result });
}
if (req.method === "POST" && url.pathname === "/api/ai/undo") {
const body = await readJson(req);
const session = previewSessions.get(body.buildId);
if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409);
const result = await undoAiProposal(session);
return sendJson(res, { ok: true, ...result });
}
const requested = url.pathname === "/" ? "/index.html" : url.pathname;
const file = path.join(publicDir, decodeURIComponent(requested));
if (!file.startsWith(publicDir)) return notFound(res);
return sendFile(res, file);
} catch (error) {
sendJson(res, { ok: false, error: error.message, ...(APP_PASSWORD ? {} : { stack: error.stack }) }, 500);
}
});
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
console.log(`Starting Archive Casino WP Builder on ${host}:${port}...`);
server.listen(port, host, () => {
console.log(`Archive Casino WP Builder is running at http://${host}:${port}/`);
console.log(APP_PASSWORD ? `Password protection is enabled for user ${APP_USERNAME}.` : "Password protection is disabled.");
});
}
function authorizeRequest(req, res) {
if (!APP_PASSWORD) return true;
const header = String(req.headers.authorization || "");
if (header.startsWith("Basic ")) {
try {
const credentials = Buffer.from(header.slice(6), "base64").toString("utf8");
const separator = credentials.indexOf(":");
const username = separator >= 0 ? credentials.slice(0, separator) : "";
const password = separator >= 0 ? credentials.slice(separator + 1) : "";
if (safeEqual(username, APP_USERNAME) && safeEqual(password, APP_PASSWORD)) return true;
} catch {}
}
res.writeHead(401, {
"WWW-Authenticate": 'Basic realm="Archive Casino Builder", charset="UTF-8"',
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store"
});
res.end("Authorization required.");
return false;
}
function safeEqual(left, right) {
const leftBuffer = Buffer.from(String(left));
const rightBuffer = Buffer.from(String(right));
if (leftBuffer.length !== rightBuffer.length) return false;
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function consumeAiRateLimit(req) {
const now = Date.now();
const windowMs = 60 * 60 * 1000;
const forwarded = String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || "");
const clientId = forwarded.split(",")[0].trim() || req.socket.remoteAddress || "unknown";
const current = aiRateLimits.get(clientId);
const entry = !current || now - current.startedAt >= windowMs ? { startedAt: now, count: 0 } : current;
if (entry.count >= AI_RATE_LIMIT_PER_HOUR) {
return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.startedAt + windowMs - now) / 1000)) };
}
entry.count += 1;
aiRateLimits.set(clientId, entry);
if (aiRateLimits.size > 500) {
for (const [key, value] of aiRateLimits) {
if (now - value.startedAt >= windowMs) aiRateLimits.delete(key);
}
}
return { ok: true };
}
async function buildProject(input) {
const config = normalizeConfig(input);
const buildId = `${Date.now()}-${slugify(config.targetDomain || "site")}`;
const buildDir = path.join(buildRoot, buildId);
const themeDir = path.join(buildDir, slugify(config.themeName));
const dataDir = path.join(themeDir, "data");
const staticDir = path.join(dataDir, "static-html");
const assetDir = path.join(dataDir, "archive-assets");
const casinoPreviewDir = path.join(buildDir, "casino-preview");
const aiReviewDir = path.join(buildDir, "ai-review");
const logs = [];
progressState = {
active: true,
buildId,
updatedAt: new Date().toISOString(),
logs: [],
summary: { pages: 0, assets: 0, casinoPages: config.casinoPages.length },
awaitingApproval: false,
preview: null,
files: []
};
const progressLogs = {
push(message) {
logs.push(message);
setProgress(message, { summary: { pages: staticPages.length, assets: assetMap.size, casinoPages: config.casinoPages.length } });
}
};
await fs.mkdir(outputsDir, { recursive: true });
await fs.mkdir(staticDir, { recursive: true });
await fs.mkdir(assetDir, { recursive: true });
await fs.mkdir(casinoPreviewDir, { recursive: true });
await fs.mkdir(aiReviewDir, { recursive: true });
await fs.mkdir(themeDir, { recursive: true });
const assetMap = new Map();
const cssTexts = [];
const staticPages = [];
const failedPages = [];
previewSessions.set(buildId, {
buildDir,
staticDir,
assetDir,
casinoPreviewDir,
aiReviewDir,
config,
ai: { messages: [], proposals: new Map(), undoStack: [] }
});
if (config.crawlDomain) {
config.urls = await discoverArchiveUrls(config, progressLogs);
progressLogs.push(`Domain discovery selected ${config.urls.length} page(s) for this build.`);
}
let shellHtmlGlobal = ""; // шапка главной страницы — пойдёт в header.php темы
let footerHtmlGlobal = ""; // футер — в footer.php
for (const sourceUrl of config.urls) {
progressLogs.push(`Resolving ${sourceUrl}`);
let snapshot;
try {
snapshot = await resolveSnapshot(sourceUrl, config);
} catch (resolveErr) {
progressLogs.push(`⚠ SKIP: не удалось найти снимок для ${sourceUrl} (${resolveErr.message}) — пропускаем`);
failedPages.push({ url: sourceUrl, reason: resolveErr.message });
continue;
}
progressLogs.push(`Using snapshot ${snapshot.timestamp} for ${snapshot.original}`);
const htmlUrl = `https://web.archive.org/web/${snapshot.timestamp}id_/${snapshot.original}`;
let rawHtml;
try {
rawHtml = await fetchText(htmlUrl);
} catch (fetchErr) {
progressLogs.push(`⚠ SKIP: не удалось скачать ${snapshot.original} (${fetchErr.message}) — пропускаем`);
failedPages.push({ url: sourceUrl, reason: fetchErr.message });
continue;
}
let html = cleanWaybackHtml(rawHtml);
html = await rewriteHtmlAssets(html, snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs);
if (!config._reservationAssetChecked && /(?:\?rezervace|Rezervace|rozm[ií]stěn[ií] stolů)/i.test(html)) {
config._reservationAssetChecked = true;
config._reservationAssetFile = await tryDownloadReservationPlan(
snapshot.original,
snapshot.timestamp,
assetDir,
assetMap,
cssTexts,
progressLogs
);
}
html = rewriteInternalSiteUrls(html, config.sourceDomain, config.targetDomain)
.replaceAll("{{ACI_SITE_URL}}", "");
// === Фаза очистки ссылок (авто-удаление 404 + внешних) ===
const cleaned = await cleanExternalLinks(html, config.targetDomain || config.sourceDomain, progressLogs);
html = cleaned.cleanedHtml;
html = normalizeStaticHtmlForTheme(html, config);
html = applySafeTextReplacements(html, config.textReplacements);
if (config.textReplacements.length) {
progressLogs.push(`Applied ${config.textReplacements.length} safe text replacement(s) without changing HTML/CSS structure.`);
}
const preparedContent = ensureEditableContentWrapper(html, config.contentSelector);
html = preparedContent.html;
const markedFooter = ensureEditableFooterMarkers(html);
if (!markedFooter.ok) throw new Error(`Cannot prepare editable footer for ${snapshot.original}: ${markedFooter.error}`);
html = markedFooter.html;
// === Разборка на части (shell / content / footer) ===
const parts = splitPageHtml(html, preparedContent.selector);
progressLogs.push(`Split: контент через "${parts.contentSelector}", content=${parts.contentHtml.length}b, shell=${parts.shellHtml.length}b`);
// запомнить shell/footer главной страницы (первой)
if (!shellHtmlGlobal && parts.shellHtml) {
shellHtmlGlobal = parts.shellHtml;
footerHtmlGlobal = parts.footerHtml;
}
// === Конвертация контента в Gutenberg-блоки ===
const gutenbergContent = htmlToGutenberg(parts.contentHtml);
const blockCount = (gutenbergContent.match(/<!-- wp:/g) || []).length;
progressLogs.push(`Converted to Gutenberg: ${blockCount} blocks for ${snapshot.original}`);
const urlObj = new URL(snapshot.original);
const requestPath = normalizeRequestPath(urlObj.pathname);
const file = `${pathNameToFileStem(requestPath)}.html`;
// Сохранить полный HTML (для превью) и конвертированный контент отдельно
await fs.writeFile(path.join(staticDir, file), html, "utf8");
staticPages.push({
title: extractTitle(html) || titleFromPath(requestPath),
sourceUrl: snapshot.original,
timestamp: snapshot.timestamp,
path: requestPath,
file,
gutenbergContent, // конвертированные блоки для WP post_content
contentSelector: parts.contentSelector
});
progressLogs.push(`Static page ready: ${requestPath}`);
}
if (config.crawlDomain && failedPages.length && staticPages.length) {
progressLogs.push(`Domain discovery build skipped ${failedPages.length} unavailable page(s) and kept ${staticPages.length} recovered page(s).`);
}
if (!staticPages.length || (!config.crawlDomain && (failedPages.length || staticPages.length !== config.urls.length))) {
const failedList = failedPages.map((item) => `${item.url}: ${item.reason}`).join("\n");
const error = new Error(
`Сборка остановлена: скачано ${staticPages.length} из ${config.urls.length} страниц. ` +
`ZIP не создан, чтобы WordPress не получил пустую или неполную тему.\n${failedList}`
);
progressState.active = false;
progressState.awaitingApproval = false;
progressState.updatedAt = new Date().toISOString();
progressState.error = error.message;
progressLogs.push(error.message);
throw error;
}
// Сохранить shell/footer для темы
config._shellHtml = shellHtmlGlobal;
config._footerHtml = footerHtmlGlobal;
const casinoPreviewPages = await writeCasinoPreviewPages(casinoPreviewDir, staticDir, staticPages, config);
progressLogs.push(`Styled Casino preview ready with ${casinoPreviewPages.length} Gutenberg page(s).`);
const routeMap = new Map();
for (const page of staticPages) {
routeMap.set(page.path.toLowerCase(), `/preview/${buildId}/pages/${encodeURIComponent(page.file)}`);
}
for (const page of casinoPreviewPages) {
routeMap.set(page.path.toLowerCase(), `/preview/${buildId}/casino/${encodeURIComponent(page.file)}`);
}
// Обновить previewSession — добавить staticPages чтобы UI редактора
// мог их показать ДО того как writeTheme создаст pages.json
const session = previewSessions.get(buildId);
if (session) {
session.staticPages = staticPages;
session.casinoPreviewPages = casinoPreviewPages;
session.routeMap = routeMap;
const firstFooter = staticPages.length
? extractEditableFooterContent(await fs.readFile(path.join(staticDir, staticPages[0].file), "utf8"))
: null;
session.footerHtml = firstFooter?.ok ? firstFooter.content : "";
}
const design = extractDesign(cssTexts.join("\n"));
const designAssets = extractDesignAssets(assetMap, cssTexts);
const manifest = {
generatedAt: new Date().toISOString(),
sourceDomain: config.sourceDomain,
targetDomain: config.targetDomain,
staticPages,
menuItems: config.menuItems,
casino: {
menuLabel: config.casinoMenuLabel,
rootPath: config.casinoRootPath,
pages: config.casinoPages
},
design,
designAssets
};
if (session) session.manifest = manifest;
const previewPages = staticPages.map((page) => ({
title: page.title,
path: page.path,
kind: "static",
url: `/preview/${buildId}/pages/${encodeURIComponent(page.file)}`
})).concat(casinoPreviewPages.map((page) => ({
title: page.title,
path: page.path,
kind: "casino",
url: `/preview/${buildId}/casino/${encodeURIComponent(page.file)}`
})));
progressState.awaitingApproval = true;
progressState.preview = { buildId, pages: previewPages };
progressState.updatedAt = new Date().toISOString();
progressLogs.push("Preview ready. Review pages and click Continue build.");
await waitForPreviewApproval(buildId);
progressLogs.push("Preview approved. Building WordPress package.");
await fs.writeFile(path.join(dataDir, "pages.json"), JSON.stringify(manifest, null, 2), "utf8");
await writeTheme(themeDir, config, manifest);
progressLogs.push("Theme-only WordPress package generated");
const themeZipName = `${slugify(config.themeName)}.zip`;
const reportName = `archive-build-report-${buildId}.html`;
await zipDirectory(themeDir, path.join(outputsDir, themeZipName), { includeRoot: false });
progressLogs.push(`ZIP ready: ${themeZipName}`);
await writeReport(path.join(outputsDir, reportName), manifest, logs, assetMap);
progressLogs.push(`Report ready: ${reportName}`);
progressState.active = false;
progressState.updatedAt = new Date().toISOString();
progressState.summary = {
pages: staticPages.length,
assets: assetMap.size,
casinoPages: config.casinoPages.length
};
progressState.files = [
{ name: themeZipName, url: `/download/${themeZipName}` },
{ name: reportName, url: `/download/${reportName}` }
];
return {
ok: true,
buildId,
files: progressState.files,
summary: {
pages: staticPages.length,
assets: assetMap.size,
casinoPages: config.casinoPages.length
},
logs
};
}
async function writeCasinoPreviewPages(previewDir, staticDir, staticPages, config) {
if (!staticPages.length) return [];
const basePage = staticPages[0];
const baseHtml = await fs.readFile(path.join(staticDir, basePage.file), "utf8");
const previews = [];
for (const page of config.casinoPages) {
const content = page.previewHtml || casinoDemoContent(page.title);
const replaced = replaceEditableContentRegion(baseHtml, basePage.contentSelector, content);
if (!replaced.ok) throw new Error(`Cannot create styled Casino preview for ${page.path}: ${replaced.error}`);
const file = `casino-${slugify(page.slug || page.title)}.html`;
const html = replaced.html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, `<title>${escapeHtml(page.title)}</title>`);
await fs.writeFile(path.join(previewDir, file), html, "utf8");
previews.push({ ...page, file });
}
return previews;
}
function ensureAiState(session) {
if (!session.ai) session.ai = { messages: [], proposals: new Map(), undoStack: [] };
if (!(session.ai.proposals instanceof Map)) session.ai.proposals = new Map();
if (!Array.isArray(session.ai.messages)) session.ai.messages = [];
if (!Array.isArray(session.ai.undoStack)) session.ai.undoStack = [];
return session.ai;
}
async function createAiReviewProposal(buildId, session, message, activePath) {
const ai = ensureAiState(session);
const selectedPath = normalizeRequestPath(activePath || session.staticPages[0]?.path || "/");
const diagnostics = await collectAiDiagnostics(session, selectedPath);
let visual;
try {
visual = await runVisualComparison(buildId, session, selectedPath);
} catch (error) {
visual = { available: false, error: `Visual comparison unavailable: ${error.message}` };
}
const context = await buildAiReviewContext(session, selectedPath, diagnostics, visual);
const proposal = sanitizeAiProposal(await requestDeepSeekProposal(ai.messages, message, context));
const proposalId = crypto.randomUUID();
const stored = { ...proposal, aiDiagnostics: proposal.diagnostics, proposalId, diagnostics, visual, createdAt: new Date().toISOString() };
ai.proposals.set(proposalId, stored);
ai.messages.push({ role: "user", content: message }, { role: "assistant", content: proposal.reply || proposal.summary });
if (ai.messages.length > 12) ai.messages = ai.messages.slice(-12);
return stored;
}
async function collectAiDiagnostics(session, activePath) {
const diagnostics = [];
const allPages = [
...(session.staticPages || []).map((page) => ({ ...page, kind: "static", dir: session.staticDir })),
...(session.casinoPreviewPages || []).map((page) => ({ ...page, kind: "casino", dir: session.casinoPreviewDir }))
];
let totalImages = 0;
let missingImages = 0;
for (const page of allPages) {
const filePath = path.join(page.dir, path.basename(page.file));
const html = await fs.readFile(filePath, "utf8");
const imageRefs = [...html.matchAll(/<img\b[^>]*\bsrc=(['"])([^'"]+)\1/gi)].map((match) => match[2]);
totalImages += imageRefs.length;
for (const ref of imageRefs) {
const local = /\{\{ACI_ASSET_URL\}\}\/([^\s"'?#<>]+)/.exec(ref);
if (local) {
try { await fs.access(path.join(session.assetDir, path.basename(local[1]))); }
catch { missingImages++; diagnostics.push({ severity: "error", title: "Missing image", detail: path.basename(local[1]), path: page.path }); }
} else if (/^https?:\/\//i.test(ref)) {
diagnostics.push({ severity: "warning", title: "External image", detail: ref.slice(0, 220), path: page.path });
}
}
const contentRegion = locateEditableContentRegion(html, page.contentSelector || session.staticPages[0]?.contentSelector);
if (!contentRegion) diagnostics.push({ severity: "error", title: "Editable content not detected", detail: page.contentSelector || "automatic", path: page.path });
if (!extractEditableFooterContent(html).ok) diagnostics.push({ severity: "warning", title: "Footer not detected", detail: "A fallback footer will be used.", path: page.path });
if (/\{\{ACI_(?:ASSET|SITE)_URL\}\}(?![\/])/i.test(html)) diagnostics.push({ severity: "warning", title: "Suspicious unresolved placeholder", detail: "Review asset or site URL placeholders.", path: page.path });
}
const failedLogs = (progressState.logs || []).filter((line) => /(?:unavailable|missing|failed|error|404|skip)/i.test(line)).slice(-12);
for (const line of failedLogs) diagnostics.push({ severity: "warning", title: "Build log", detail: line.slice(0, 400), path: activePath });
if (!missingImages) diagnostics.push({ severity: "ok", title: "Local images", detail: `${totalImages} image references checked; no missing local files found.`, path: activePath });
if (!diagnostics.some((item) => item.severity === "error")) diagnostics.unshift({ severity: "ok", title: "Page structure", detail: "Editable content and footer regions are available.", path: activePath });
return diagnostics.slice(0, 30);
}
async function buildAiReviewContext(session, activePath, diagnostics, visual) {
const staticPage = (session.staticPages || []).find((page) => normalizeRequestPath(page.path) === activePath);
const casinoPage = (session.casinoPreviewPages || []).find((page) => normalizeRequestPath(page.path) === activePath);
const selected = staticPage || casinoPage || session.staticPages[0];
const selectedDir = casinoPage ? session.casinoPreviewDir : session.staticDir;
const html = await fs.readFile(path.join(selectedDir, path.basename(selected.file)), "utf8");
const selector = selected.contentSelector || session.staticPages[0]?.contentSelector;
const region = locateEditableContentRegion(html, selector);
const content = region ? html.slice(region.innerStart, region.innerEnd) : html;
const footer = extractEditableFooterContent(html);
return JSON.stringify({
activePage: { title: selected.title, path: selected.path, kind: casinoPage ? "casino" : "static", contentSelector: selector },
pages: [
...(session.staticPages || []).map((page) => ({ title: page.title, path: page.path, kind: "static", selector: page.contentSelector })),
...(session.casinoPreviewPages || []).map((page) => ({ title: page.title, path: page.path, kind: "casino" }))
],
menuItems: session.config?.menuItems || [],
casinoPages: session.config?.casinoPages || [],
footerHtml: (footer.ok ? footer.content : "").slice(0, 8000),
contentHtml: content.slice(0, 18000),
diagnostics,
visual
});
}
async function requestDeepSeekProposal(history, userMessage, context) {
const operationSchema = {
type: "object",
properties: {
type: { type: "string", enum: ["replace_text", "set_footer_html", "set_page_content", "append_css", "set_menu_items", "set_content_selector"] },
path: { type: "string" },
scope: { type: "string", enum: ["all", "static", "casino"] },
find: { type: "string" },
replace: { type: "string" },
html: { type: "string" },
css: { type: "string" },
selector: { type: "string" },
items: { type: "array", items: { type: "object", properties: { title: { type: "string" }, path: { type: "string" } }, required: ["title", "path"] } }
},
required: ["type"]
};
const tools = [{
type: "function",
function: {
name: "propose_preview_changes",
description: "Return a review and a limited list of safe changes. Changes are shown to the user and are not applied automatically.",
parameters: {
type: "object",
properties: {
reply: { type: "string" },
summary: { type: "string" },
diagnostics: { type: "array", items: { type: "string" } },
risks: { type: "array", items: { type: "string" } },
operations: { type: "array", items: operationSchema }
},
required: ["reply", "summary", "diagnostics", "risks", "operations"]
}
}
}];
const system = [
"You are the AI Review assistant inside a local Wayback-to-WordPress theme builder.",
"Reply in the user's language. Diagnose clearly and propose only necessary operations.",
"Never invent recovered facts or missing images. Never add scripts, trackers, affiliate links, remote assets, PHP, or WordPress credentials.",
"Use set_page_content only for an existing path. Preserve the site's structural classes when rewriting HTML.",
"Use append_css for visual adaptation, set_footer_html for the global footer, and set_menu_items only when the user explicitly asks about navigation.",
"Casino article content should be semantic HTML suitable for conversion to Gutenberg. Do not add unverifiable casino ratings or claims.",
"If the user asks only for analysis, return an empty operations array."
].join(" ");
const messages = [
{ role: "system", content: system },
...history.slice(-8),
{ role: "user", content: `BUILD CONTEXT:\n${context}\n\nUSER REQUEST:\n${userMessage}` }
];
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}` },
body: JSON.stringify({ model: DEEPSEEK_MODEL, messages, tools, tool_choice: { type: "function", function: { name: "propose_preview_changes" } }, thinking: { type: "disabled" }, max_tokens: 5000 }),
signal: AbortSignal.timeout(90000)
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`DeepSeek API ${response.status}: ${payload.error?.message || "request failed"}`);
const call = payload.choices?.[0]?.message?.tool_calls?.find((item) => item.function?.name === "propose_preview_changes");
if (!call?.function?.arguments) throw new Error("DeepSeek returned no structured review proposal.");
try { return JSON.parse(call.function.arguments); }
catch { throw new Error("DeepSeek returned invalid proposal JSON."); }
}
function sanitizeAiProposal(raw) {
const allowed = new Set(["replace_text", "set_footer_html", "set_page_content", "append_css", "set_menu_items", "set_content_selector"]);
const operations = [];
for (const source of Array.isArray(raw?.operations) ? raw.operations.slice(0, 12) : []) {
if (!allowed.has(source?.type)) continue;
const operation = {
type: source.type,
path: normalizeRequestPath(source.path || "/"),
scope: ["all", "static", "casino"].includes(source.scope) ? source.scope : "all",
find: String(source.find || "").slice(0, 2000),
replace: String(source.replace || "").slice(0, 10000),
html: String(source.html || "").slice(0, 150000),
css: String(source.css || "").slice(0, 50000),
selector: String(source.selector || "").trim().replace(/^\./, "").slice(0, 120),
items: Array.isArray(source.items) ? source.items.slice(0, 30).map((item) => ({ title: String(item.title || "").slice(0, 120), path: normalizeRequestPath(item.path || "/") })).filter((item) => item.title) : []
};
if ((operation.type === "set_footer_html" || operation.type === "set_page_content") && validateEditableHtmlFragment(operation.html)) continue;
if (operation.type === "append_css" && /(?:<\/style|@import|javascript:|expression\s*\()/i.test(operation.css)) continue;
if (operation.type === "set_content_selector" && !/^[a-zA-Z0-9_-]+$/.test(operation.selector)) continue;
operations.push(operation);
}
return {
reply: String(raw?.reply || raw?.summary || "Review ready.").slice(0, 8000),
summary: String(raw?.summary || "AI Review proposal").slice(0, 500),
diagnostics: Array.isArray(raw?.diagnostics) ? raw.diagnostics.slice(0, 20).map((item) => String(item).slice(0, 500)) : [],
risks: Array.isArray(raw?.risks) ? raw.risks.slice(0, 12).map((item) => String(item).slice(0, 500)) : [],
operations
};
}
async function applyAiProposal(buildId, session, proposalId) {
const ai = ensureAiState(session);
const proposal = ai.proposals.get(proposalId);
if (!proposal) throw new Error("AI proposal was not found or was already applied.");
const backup = await snapshotAiState(session, proposal.summary);
try {
for (const operation of proposal.operations) await applyAiOperation(session, operation);
} catch (error) {
await restoreAiState(session, backup);
throw error;
}
ai.undoStack.push(backup);
if (ai.undoStack.length > 8) ai.undoStack.shift();
ai.proposals.delete(proposalId);
const previewVersion = Date.now();
return { applied: proposal.operations.length, summary: proposal.summary, previewVersion, canUndo: true };
}
async function applyAiOperation(session, operation) {
const staticTargets = (session.staticPages || []).map((page) => ({ page, dir: session.staticDir, kind: "static" }));
const casinoTargets = (session.casinoPreviewPages || []).map((page) => ({ page, dir: session.casinoPreviewDir, kind: "casino" }));
const allTargets = operation.scope === "static" ? staticTargets : operation.scope === "casino" ? casinoTargets : [...staticTargets, ...casinoTargets];
if (operation.type === "replace_text") {
if (!operation.find) throw new Error("AI replace_text operation has no source text.");
for (const target of allTargets) {
const filePath = path.join(target.dir, path.basename(target.page.file));
const html = await fs.readFile(filePath, "utf8");
await fs.writeFile(filePath, applySafeTextReplacements(html, [{ from: operation.find, to: operation.replace }]), "utf8");
if (target.page.gutenbergContent) target.page.gutenbergContent = applySafeTextReplacements(target.page.gutenbergContent, [{ from: operation.find, to: operation.replace }]);
const manifestPage = target.kind === "static"
? session.manifest?.staticPages?.find((page) => normalizeRequestPath(page.path) === normalizeRequestPath(target.page.path))
: session.manifest?.casino?.pages?.find((page) => normalizeRequestPath(page.path) === normalizeRequestPath(target.page.path));
if (manifestPage && target.page.gutenbergContent) manifestPage.gutenbergContent = target.page.gutenbergContent;
}
return;
}
if (operation.type === "set_footer_html") {
if (validateEditableHtmlFragment(operation.html)) throw new Error("AI footer HTML did not pass validation.");
for (const target of [...staticTargets, ...casinoTargets]) {
const filePath = path.join(target.dir, path.basename(target.page.file));
const replaced = replaceEditableFooterRegion(await fs.readFile(filePath, "utf8"), operation.html);
if (!replaced.ok) throw new Error(replaced.error);
await fs.writeFile(filePath, replaced.html, "utf8");
}
session.footerHtml = operation.html;
if (session.manifest) session.manifest.footer = { html: operation.html };
return;
}
if (operation.type === "set_page_content") {
const target = [...staticTargets, ...casinoTargets].find((item) => normalizeRequestPath(item.page.path) === operation.path);
if (!target) throw new Error(`AI target page does not exist: ${operation.path}`);
if (validateEditableHtmlFragment(operation.html)) throw new Error("AI page HTML did not pass validation.");
const filePath = path.join(target.dir, path.basename(target.page.file));
const selector = target.page.contentSelector || session.staticPages[0]?.contentSelector;
const replaced = replaceEditableContentRegion(await fs.readFile(filePath, "utf8"), selector, operation.html);
if (!replaced.ok) throw new Error(replaced.error);
await fs.writeFile(filePath, replaced.html, "utf8");
target.page.gutenbergContent = htmlToGutenberg(operation.html);
const manifestPage = target.kind === "static"
? session.manifest?.staticPages?.find((page) => normalizeRequestPath(page.path) === operation.path)
: session.manifest?.casino?.pages?.find((page) => normalizeRequestPath(page.path) === operation.path);
if (manifestPage) manifestPage.gutenbergContent = target.page.gutenbergContent;
return;
}
if (operation.type === "append_css") {
if (!operation.css || /(?:<\/style|@import|javascript:|expression\s*\()/i.test(operation.css)) throw new Error("AI CSS did not pass validation.");
const previous = String(session.manifest?.aiCustomCss || "");
const combined = [previous, operation.css].filter(Boolean).join("\n").slice(-100000);
if (session.manifest) session.manifest.aiCustomCss = combined;
for (const target of [...staticTargets, ...casinoTargets]) {
const filePath = path.join(target.dir, path.basename(target.page.file));
await fs.writeFile(filePath, injectAiCustomCss(await fs.readFile(filePath, "utf8"), combined), "utf8");
}
return;
}
if (operation.type === "set_menu_items") {
if (!operation.items.length) throw new Error("AI menu operation has no items.");
session.config.menuItems = operation.items;
if (session.manifest) session.manifest.menuItems = operation.items;
for (const target of [...staticTargets, ...casinoTargets]) {
const filePath = path.join(target.dir, path.basename(target.page.file));
await fs.writeFile(filePath, pruneStaticMenus(await fs.readFile(filePath, "utf8"), session.config), "utf8");
}
return;
}
if (operation.type === "set_content_selector") {
const target = staticTargets.find((item) => normalizeRequestPath(item.page.path) === operation.path);
if (!target) throw new Error(`Static page does not exist: ${operation.path}`);
const filePath = path.join(target.dir, path.basename(target.page.file));
const html = await fs.readFile(filePath, "utf8");
const region = locateEditableContentRegion(html, operation.selector);
if (!region || region.selector !== operation.selector) throw new Error(`Content selector was not found: ${operation.selector}`);
target.page.contentSelector = operation.selector;
target.page.gutenbergContent = htmlToGutenberg(html.slice(region.innerStart, region.innerEnd));
const manifestPage = session.manifest?.staticPages?.find((page) => normalizeRequestPath(page.path) === operation.path);
if (manifestPage) Object.assign(manifestPage, { contentSelector: operation.selector, gutenbergContent: target.page.gutenbergContent });
}
}
function injectAiCustomCss(html, css) {
const style = `<style id="acwpb-ai-custom-css">\n${css}\n</style>`;
if (/<style\b[^>]*id=["']acwpb-ai-custom-css["'][^>]*>[\s\S]*?<\/style>/i.test(html)) {
return html.replace(/<style\b[^>]*id=["']acwpb-ai-custom-css["'][^>]*>[\s\S]*?<\/style>/i, style);
}
return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, style + "\n</head>") : style + html;
}
async function snapshotAiState(session, label) {
const targets = [
...(session.staticPages || []).map((page) => path.join(session.staticDir, path.basename(page.file))),
...(session.casinoPreviewPages || []).map((page) => path.join(session.casinoPreviewDir, path.basename(page.file)))
];
const files = [];
for (const filePath of targets) files.push({ filePath, html: await fs.readFile(filePath, "utf8") });
return {
label,
files,
staticPages: structuredClone(session.staticPages || []),
casinoPreviewPages: structuredClone(session.casinoPreviewPages || []),
menuItems: structuredClone(session.config?.menuItems || []),
casinoPages: structuredClone(session.config?.casinoPages || []),
footerHtml: session.footerHtml || "",
aiCustomCss: session.manifest?.aiCustomCss || ""
};
}
async function restoreAiState(session, snapshot) {
for (const file of snapshot.files) await fs.writeFile(file.filePath, file.html, "utf8");
session.staticPages = structuredClone(snapshot.staticPages);
session.casinoPreviewPages = structuredClone(snapshot.casinoPreviewPages);
session.config.menuItems = structuredClone(snapshot.menuItems);
session.config.casinoPages = structuredClone(snapshot.casinoPages);
session.footerHtml = snapshot.footerHtml;
if (session.manifest) {
session.manifest.staticPages = session.staticPages;
session.manifest.menuItems = session.config.menuItems;
session.manifest.casino.pages = session.config.casinoPages;
session.manifest.footer = { html: snapshot.footerHtml };
session.manifest.aiCustomCss = snapshot.aiCustomCss;
}
}
async function undoAiProposal(session) {
const ai = ensureAiState(session);
const snapshot = ai.undoStack.pop();
if (!snapshot) throw new Error("There are no AI changes to undo.");
await restoreAiState(session, snapshot);
return { undone: snapshot.label, previewVersion: Date.now(), canUndo: ai.undoStack.length > 0 };
}
async function runVisualComparison(buildId, session, activePath) {
if (!(await fileExists(EDGE_PATH))) return { available: false, error: "Microsoft Edge was not found for screenshots." };
const selectedStatic = (session.staticPages || []).find((page) => normalizeRequestPath(page.path) === activePath);
const selectedCasino = (session.casinoPreviewPages || []).find((page) => normalizeRequestPath(page.path) === activePath);
const sourcePage = selectedStatic || session.staticPages[0];
const previewPage = selectedStatic || selectedCasino || sourcePage;
const previewKind = selectedCasino ? "casino" : "pages";
const key = slugify(previewPage.path || previewPage.title || "page");
const originalFile = path.join(session.aiReviewDir, `${key}-original.png`);
const previewFile = path.join(session.aiReviewDir, `${key}-preview.png`);
const diffFile = path.join(session.aiReviewDir, `${key}-diff.png`);
const originalUrl = `https://web.archive.org/web/${sourcePage.timestamp}id_/${sourcePage.sourceUrl}`;
const previewUrl = `http://localhost:${port}/preview/${buildId}/${previewKind}/${encodeURIComponent(previewPage.file)}`;
await captureEdgeScreenshot(originalUrl, originalFile, `${key}-original`);
await captureEdgeScreenshot(previewUrl, previewFile, `${key}-preview`);
const width = 1100;
const height = 900;
const [original, preview] = await Promise.all([
sharp(originalFile).resize(width, height, { fit: "fill" }).removeAlpha().raw().toBuffer(),
sharp(previewFile).resize(width, height, { fit: "fill" }).removeAlpha().raw().toBuffer()
]);
const diff = Buffer.alloc(width * height * 3);
let delta = 0;
for (let index = 0; index < diff.length; index++) {
const value = Math.abs(original[index] - preview[index]);
delta += value;
diff[index] = Math.min(255, value * 3);
}
await sharp(diff, { raw: { width, height, channels: 3 } }).png().toFile(diffFile);
const meanDifference = delta / diff.length / 255;
return {
available: true,
comparedPath: previewPage.path,
sourcePath: sourcePage.path,
similarity: Math.max(0, Math.round((1 - meanDifference) * 1000) / 10),
originalUrl: `/preview/${buildId}/ai/${path.basename(originalFile)}`,
previewUrl: `/preview/${buildId}/ai/${path.basename(previewFile)}`,
diffUrl: `/preview/${buildId}/ai/${path.basename(diffFile)}`
};
}
async function captureEdgeScreenshot(url, destination, profileName) {
const profile = path.join(path.dirname(destination), `edge-${profileName}`);
await runChildProcess(EDGE_PATH, [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
`--user-data-dir=${profile}`,
"--window-size=1440,1200",
"--virtual-time-budget=12000",
`--screenshot=${destination}`,
url
], 45000);
if (!(await fileExists(destination))) throw new Error("Edge did not create a screenshot.");
}
function runChildProcess(command, args, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { windowsHide: true, stdio: "ignore" });
const timer = setTimeout(() => { child.kill(); reject(new Error("Screenshot process timed out.")); }, timeoutMs);
child.once("error", (error) => { clearTimeout(timer); reject(error); });
child.once("exit", (code) => {
clearTimeout(timer);
code === 0 ? resolve() : reject(new Error(`Screenshot process exited with code ${code}.`));
});
});
}
async function fileExists(file) {
try { await fs.access(file); return true; }
catch { return false; }
}
function setProgress(message, patch = {}) {
const time = new Date().toLocaleTimeString("ru-RU", { hour12: false });
progressState.logs.push(`[${time}] ${message}`);
if (progressState.logs.length > 500) progressState.logs.shift();
progressState = {
...progressState,
...patch,
updatedAt: new Date().toISOString()
};
}
function waitForPreviewApproval(buildId) {
return new Promise((resolve) => {
approvalWaiters.set(buildId, resolve);
});
}
// Прочитать manifest (pages.json) текущего билда
async function getBuildManifest(buildId) {
const session = previewSessions.get(buildId);
if (!session) return null;
try {
const dataDir = path.join(session.buildDir, findThemeDir(session.buildDir), "data");
const raw = await fs.readFile(path.join(dataDir, "pages.json"), "utf8");
return JSON.parse(raw);
} catch { return null; }
}
// Обновить manifest (pages.json) текущего билда
async function updateBuildManifest(buildId, updater) {
const session = previewSessions.get(buildId);
if (!session) return;
try {
const themeDir = findThemeDir(session.buildDir);
const dataDir = path.join(session.buildDir, themeDir, "data");
const manifestPath = path.join(dataDir, "pages.json");
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
updater(manifest);
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
} catch (e) { /* manifest может ещё не существовать */ }
}
// Найти папку темы внутри buildDir
function findThemeDir(buildDir) {
try {
const entries = require("fs").readdirSync(buildDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && e.name !== "data" && e.name !== "node_modules") {
// проверить есть ли style.css
try {
require("fs").accessSync(path.join(buildDir, e.name, "style.css"));
return e.name;
} catch {}
}
}
} catch {}
// fallback — искать data/pages.json
try {
const entries = require("fs").readdirSync(buildDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
try {
require("fs").accessSync(path.join(buildDir, e.name, "data", "pages.json"));
return e.name;
} catch {}
}
}
} catch {}
return "";
}
async function sendPreviewFile(res, pathname) {
const parts = pathname.split("/").filter(Boolean);
const [, buildId, kind, rawName] = parts;
const session = previewSessions.get(buildId);
if (!session || !rawName) return notFound(res);
const name = path.basename(decodeURIComponent(rawName));
if (kind === "pages" || kind === "casino") {
const baseDir = kind === "casino" ? session.casinoPreviewDir : session.staticDir;
if (!baseDir) return notFound(res);
const file = path.join(baseDir, name);
if (!file.startsWith(baseDir)) return notFound(res);
try {
const html = await fs.readFile(file, "utf8");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
const rendered = html
.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`)
.replaceAll("{{ACI_SITE_URL}}", "/");
res.end(rewritePreviewNavigation(rendered, session.routeMap));
} catch {
notFound(res);
}
return;
}
if (kind === "ai") {
const file = path.join(session.aiReviewDir || "", name);
if (!session.aiReviewDir || !file.startsWith(session.aiReviewDir)) return notFound(res);
return sendFile(res, file);
}
if (kind === "assets") {
const file = path.join(session.assetDir, name);
if (!file.startsWith(session.assetDir)) return notFound(res);
return sendFile(res, file);
}
return notFound(res);
}
function rewritePreviewNavigation(html, routeMap) {
if (!(routeMap instanceof Map) || routeMap.size === 0) return html;
return html.replace(/\bhref=(['"])([^'"]+)\1/gi, (whole, quote, raw) => {
if (!raw || /^(?:#|mailto:|tel:|javascript:)/i.test(raw)) return whole;
try {
const parsed = new URL(raw, "https://preview.local/");
const key = normalizeRequestPath(parsed.pathname).toLowerCase();
const target = routeMap.get(key);
return target ? `href=${quote}${target}${quote}` : whole;
} catch {
return whole;
}
});
}
const DEFAULT_STATIC_MENU = "/cz/1-O-restauraci/ | O restauraci\n/cz/9-Poledni-menu/ | Polední menu\n/cz/15-Fotogalerie/ | Fotogalerie\n/cz/17-Kontakty/ | Kontakty";
function parseMenuItems(value) {
const source = value === undefined || value === null ? DEFAULT_STATIC_MENU : value;
return String(source)
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split("|").map((part) => part.trim()).filter(Boolean);
let pathValue = parts.length >= 2 ? parts[0] : line;
let title = parts.length >= 2 ? parts.slice(1).join(" | ") : titleFromPath(pathValue);
pathValue = pathValue.replace(/^https?:\/\/[^/]+/i, "");
if (!pathValue.startsWith("/")) pathValue = "/" + pathValue;
if (!pathValue.endsWith("/")) pathValue += "/";
return { title: title || pathValue, path: pathValue.replace(/\/+/g, "/") };
});
}
function parseTextReplacements(value) {
return String(value || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const separator = line.indexOf("=>");
if (separator < 1) throw new Error(`Text replacement line ${index + 1} must use: old text => new text`);
const from = line.slice(0, separator).trim();
const to = line.slice(separator + 2).trim();
if (!from) throw new Error(`Text replacement line ${index + 1} has an empty source value.`);
if (/[<>]/.test(from + to)) throw new Error(`Text replacement line ${index + 1} cannot contain HTML brackets.`);
return { from, to };
});
}
function applySafeTextReplacements(html, replacements) {
if (!Array.isArray(replacements) || replacements.length === 0) return String(html || "");
const protectedBlocks = [];
let protectedHtml = String(html || "").replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, (block) => {
const token = `\u0000ACWPB_BLOCK_${protectedBlocks.length}\u0000`;
protectedBlocks.push(block);
return token;
});
const replaceValue = (value) => {
let result = value;
for (const { from, to } of replacements) result = result.split(from).join(to);
return result;
};
protectedHtml = protectedHtml.replace(/<[^>]+>|[^<]+/g, (part) => {
if (!part.startsWith("<")) return replaceValue(part);
if (/^<!--/.test(part)) return part;
return part.replace(/\b(alt|title|aria-label|placeholder|value|href)=(['"])([\s\S]*?)\2/gi,
(attribute, name, quote, value) => {
if (name.toLowerCase() === "href" && !/^(?:mailto|tel):/i.test(value)) return attribute;
return `${name}=${quote}${replaceValue(value)}${quote}`;
});
});
return protectedHtml.replace(/\u0000ACWPB_BLOCK_(\d+)\u0000/g, (_token, index) => protectedBlocks[Number(index)] || "");
}
const DEFAULT_CASINO_PAGES = "/casino/best-casinos/ | Best Casinos\n/casino/neteller-casino/ | Neteller Casino\n/casino/skrill-casino/ | Skrill Casino";
function parseCasinoPages(value, nestedCasino = true) {
const lines = String(value || DEFAULT_CASINO_PAGES)
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const pages = lines.map((line, index) => parseCasinoPageLine(line, index, nestedCasino));
// НЕ добавляем корневую /casino/ — Casino теперь только аккордеон-родитель в меню,
// без отдельной страницы. Все casino-страницы создаются внутри /casino/<slug>/.
const seen = new Set();
return pages.filter((page) => {
if (seen.has(page.path)) return false;
seen.add(page.path);
return true;
});
}
function parseCasinoPageLine(line, index, nestedCasino) {
const parts = line.split("|").map((part) => part.trim()).filter(Boolean);
let pathPart = "";
let titlePart = "";
if (parts.length >= 2) {
const leftLooksPath = looksLikePath(parts[0]);
const rightLooksPath = looksLikePath(parts[1]);
if (leftLooksPath || !rightLooksPath) {
pathPart = parts[0];
titlePart = parts.slice(1).join(" | ");
} else {
titlePart = parts[0];
pathPart = parts[1];
}
} else if (looksLikePath(line)) {
pathPart = line;
titlePart = titleFromPath(line);
} else {
titlePart = line;
}
const title = titlePart || titleFromPath(pathPart) || "Casino";
const path = normalizeCasinoPath(pathPart, title, index, nestedCasino);
const slug = slugFromCasinoPath(path);
return { title, slug, path };
}
function looksLikePath(value) {
return /^\/?casino(?:\/|$)/i.test(value) || /^\//.test(value) || /\//.test(value);
}
function normalizeCasinoPath(pathPart, title, index, nestedCasino) {
let pathValue = String(pathPart || "").trim();
if (!pathValue) {
const slug = slugify(title.replace(/^casino\s+/i, ""));
return nestedCasino ? "/casino/" + slug + "/" : "/" + slug + "/";
}
pathValue = pathValue.replace(/^https?:\/\/[^/]+/i, "");
if (!pathValue.startsWith("/")) pathValue = "/" + pathValue;
if (!pathValue.endsWith("/")) pathValue += "/";
return pathValue.replace(/\/+/g, "/");
}
function slugFromCasinoPath(pathValue) {
const clean = String(pathValue || "/casino/").replace(/^\/+|\/+$/g, "");
const last = clean.split("/").filter(Boolean).pop() || "casino";
return slugify(last) || "casino";
}
function normalizeConfig(input) {
const crawlDomain = input.crawlDomain === true || input.crawlDomain === "true" || input.crawlDomain === "on";
const urls = String(input.urls || "")
.split(/\r?\n|,/)
.map((value) => value.trim())
.filter(Boolean);
const casinoPages = parseCasinoPages(input.casinoPages, input.nestedCasino !== false);
const menuItems = parseMenuItems(input.staticMenuItems);
const textReplacements = parseTextReplacements(input.textReplacements);
let sourceDomain = cleanDomain(input.sourceDomain);
if (!sourceDomain) {
try {
const archived = parseWaybackUrl(urls[0]);
const original = archived ? archived.original : normalizeOriginalUrl(urls[0]);
sourceDomain = cleanDomain(new URL(original).hostname);
} catch {}
}
const targetDomain = cleanDomain(input.targetDomain) || sourceDomain;
if (!urls.length && crawlDomain && sourceDomain) urls.push(`https://${sourceDomain}/`);
if (!urls.length) throw new Error("Add at least one URL, or enter a source domain and enable automatic domain discovery.");
const maxPages = Math.min(500, Math.max(1, Number.parseInt(input.maxPages, 10) || 100));
return {
sourceDomain,
targetDomain,
snapshotMode: input.snapshotMode || "latest",
snapshotDate: input.snapshotDate || "",
snapshotTimestamp: input.snapshotTimestamp || "",
themeName: input.themeName || "Recovered Casino Theme",
casinoMenuLabel: input.casinoMenuLabel || "Casino",
casinoRootPath: "/casino/",
menuItems,
textReplacements,
crawlDomain,
maxPages,
nestedCasino: input.nestedCasino !== false,
urls,
casinoPages
};
}
async function discoverArchiveUrls(config, logs) {
const domain = cleanDomain(config.sourceDomain);
if (!domain) throw new Error("Source domain is required for automatic discovery.");
const archivedInput = config.urls.map(parseWaybackUrl).find(Boolean);
const cutoff = archivedInput?.timestamp ||
(/^\d{8,14}$/.test(config.snapshotTimestamp) ? config.snapshotTimestamp : "") ||
(config.snapshotDate ? config.snapshotDate.replaceAll("-", "") + "235959" : "");
const to = cutoff ? `&to=${cutoff}` : "";
config._discoveryCutoff = cutoff;
const queryLimit = Math.min(2000, Math.max(500, config.maxPages * 4));
const cdx = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(domain + "/*")}&output=json&fl=timestamp,original,statuscode,mimetype&filter=statuscode:200&filter=mimetype:text/html&collapse=urlkey&sort=reverse&limit=${queryLimit}${to}`;
logs.push(`Discovering archived HTML pages for ${domain} (limit ${config.maxPages})...`);
let rows;
try {
rows = await fetchJson(cdx);
} catch (error) {
logs.push(`Domain discovery failed; explicit URLs will still be used (${error.message}).`);
return config.urls;
}
const wantedHost = domain.replace(/^www\./i, "").toLowerCase();
const preferredPrefixes = config.urls.map((candidate) => {
try {
const archived = parseWaybackUrl(candidate);
const parsed = new URL(archived?.original || normalizeOriginalUrl(candidate));
const first = parsed.pathname.split("/").filter(Boolean)[0];
return first ? `/${first.toLowerCase()}/` : "";
} catch { return ""; }
}).filter(Boolean);
const discovered = [];
for (const row of Array.isArray(rows) ? rows.slice(1) : []) {
const [timestamp, original] = row;
if (!timestamp || !original) continue;
let parsed;
try { parsed = new URL(original); } catch { continue; }
if (parsed.hostname.replace(/^www\./i, "").toLowerCase() !== wantedHost) continue;
if (/\.(?:css|js|json|xml|png|jpe?g|gif|webp|svg|ico|pdf|zip|mp4|webm|woff2?|ttf)$/i.test(parsed.pathname)) continue;
if (/(?:\/wp-admin\/|\/wp-login\.php|\/feed\/?$|\/xmlrpc\.php)/i.test(parsed.pathname)) continue;
parsed.search = "";
parsed.hash = "";
discovered.push({ url: parsed.href, timestamp, path: normalizeRequestPath(parsed.pathname).toLowerCase() });
}
discovered.sort((left, right) => {
const leftPreferred = preferredPrefixes.some((prefix) => left.path.startsWith(prefix)) ? 1 : 0;
const rightPreferred = preferredPrefixes.some((prefix) => right.path.startsWith(prefix)) ? 1 : 0;
return rightPreferred - leftPreferred || String(right.timestamp).localeCompare(String(left.timestamp)) || left.path.localeCompare(right.path);
});
const merged = [];
const seen = new Set();
for (const candidate of [...config.urls, ...discovered.map((item) => item.url)]) {
try {
const archived = parseWaybackUrl(candidate);
const original = archived?.original || normalizeOriginalUrl(candidate);
const parsed = new URL(original);
const key = parsed.hostname.replace(/^www\./i, "").toLowerCase() + normalizeRequestPath(parsed.pathname).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(candidate);
if (merged.length >= config.maxPages) break;
} catch {}
}
logs.push(`WebArchive returned ${discovered.length} usable HTML capture(s); ${merged.length} unique page(s) selected.`);
return merged.length ? merged : config.urls;
}
async function resolveSnapshot(rawUrl, config) {
const parsed = parseWaybackUrl(rawUrl);
if (parsed) return parsed;
const original = normalizeOriginalUrl(rawUrl);
if (config.snapshotMode === "timestamp" && /^\d{8,14}$/.test(config.snapshotTimestamp)) {
return { original, timestamp: config.snapshotTimestamp };
}
const toTimestamp = config._discoveryCutoff ||
(config.snapshotMode === "date" && config.snapshotDate ? `${config.snapshotDate.replaceAll("-", "")}235959` : "");
const to = toTimestamp ? `&to=${toTimestamp}` : "";
const cdx = `https://web.archive.org/cdx?url=${encodeURIComponent(original)}&output=json&fl=timestamp,original,statuscode,mimetype&filter=statuscode:200&filter=mimetype:text/html&collapse=digest&sort=reverse&limit=1${to}`;
try {
const rows = await fetchJson(cdx);
if (Array.isArray(rows) && rows.length > 1) {
const parsedOriginal = parseWaybackUrl(rows[1][1]);
return { timestamp: rows[1][0], original: parsedOriginal?.original || normalizeOriginalUrl(rows[1][1]) };
}
} catch {
// Fall back to availability API below.
}
const available = await fetchJson(`https://archive.org/wayback/available?url=${encodeURIComponent(original)}`);
const closest = available?.archived_snapshots?.closest;
if (!closest?.timestamp) throw new Error(`Не найден снимок WebArchive для ${original}`);
const parsedClosest = parseWaybackUrl(closest.url);
const closestOriginal = parsedClosest?.original || closest.url.replace(/^https?:\/\/web\.archive\.org\/web\/\d{8,14}(?:[a-z_]+)?\//i, "");
return { timestamp: closest.timestamp, original: normalizeOriginalUrl(closestOriginal) };
}
function parseWaybackUrl(value) {
const match = String(value).match(/web\.archive\.org\/web\/(\d{8,14})(?:[a-z_]+)?\/(https?:\/\/.+)$/i);
if (!match) return null;
return { timestamp: match[1], original: normalizeOriginalUrl(match[2]) };
}
async function rewriteHtmlAssets(html, pageUrl, timestamp, assetDir, assetMap, cssTexts, logs) {
const attrPattern = /\b(src|href)=["']([^"']+)["']/gi;
const replacements = [];
let match;
while ((match = attrPattern.exec(html))) {
const [full, attr, raw] = match;
if (!shouldDownloadAsset(raw, attr)) continue;
const absolute = toAbsoluteAssetUrl(raw, pageUrl);
if (!absolute) continue;
replacements.push({ full, raw, absolute, attr });
}
const unique = [...new Map(replacements.map((item) => [item.absolute, item])).values()];
logs.push(`Downloading ${unique.length} page asset(s) in small parallel batches...`);
const batchSize = 4;
for (let index = 0; index < unique.length; index += batchSize) {
const batch = unique.slice(index, index + batchSize);
const results = await Promise.allSettled(batch.map(async (item) => ({
item,
local: await downloadAsset(item.absolute, timestamp, assetDir, assetMap, cssTexts, logs)
})));
for (const [resultIndex, result] of results.entries()) {
if (result.status === "fulfilled") {
const { item, local } = result.value;
for (const occurrence of replacements.filter((candidate) => candidate.absolute === item.absolute)) {
html = html.replaceAll(occurrence.raw, `{{ACI_ASSET_URL}}/${local}`);
}
} else {
const failedItem = batch[resultIndex];
logs.push(`Asset unavailable and removed from HTML: ${failedItem.absolute} (${result.reason?.message || "download failed"})`);
for (const occurrence of replacements.filter((candidate) => candidate.absolute === failedItem.absolute)) {
html = removeUnavailableAssetReference(html, occurrence);
}
}
}
logs.push(`Assets processed: ${Math.min(index + batch.length, unique.length)}/${unique.length} (${assetMap.size} stored).`);
}
return html;
}
async function downloadAsset(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs) {
if (assetMap.has(assetUrl)) return assetMap.get(assetUrl).file;
const promiseKey = `${assetDir}|${timestamp}|${assetUrl}`;
if (assetDownloadPromises.has(promiseKey)) return assetDownloadPromises.get(promiseKey);
const promise = downloadAssetUncached(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs);
assetDownloadPromises.set(promiseKey, promise);
try {
return await promise;
} finally {
assetDownloadPromises.delete(promiseKey);
}
}
async function downloadAssetUncached(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs) {
if (assetMap.has(assetUrl)) return assetMap.get(assetUrl).file;
if (assetMap.size >= MAX_ASSETS) {
throw new Error(`asset limit ${MAX_ASSETS} reached`);
}
const parsed = new URL(assetUrl);
const ext = guessExtension(parsed.pathname);
const file = `${hash(assetUrl)}${ext}`;
const outPath = path.join(assetDir, file);
const archivedUrl = `https://web.archive.org/web/${timestamp}id_/${assetUrl}`;
let response;
let lastError;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
response = await fetchWithTimeout(archivedUrl, ASSET_TIMEOUT_MS);
if (response.ok || ![408, 425, 429, 500, 502, 503, 504].includes(response.status)) break;
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
if (attempt < 3) {
logs.push(`Retrying asset (${attempt}/2): ${assetUrl}`);
await delay(750 * attempt);
}
}
if (!response) throw lastError || new Error("fetch failed");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let buffer = Buffer.from(await response.arrayBuffer());
if (ext === ".css") {
let css = buffer.toString("utf8");
css = await rewriteCssAssets(css, assetUrl, timestamp, assetDir, assetMap, cssTexts, logs);
cssTexts.push(css);
buffer = Buffer.from(css, "utf8");
}
await fs.writeFile(outPath, buffer);
assetMap.set(assetUrl, { file, bytes: buffer.length });
return file;
}
async function tryDownloadReservationPlan(pageUrl, timestamp, assetDir, assetMap, cssTexts, logs) {
const page = new URL(pageUrl);
const origins = [...new Set([
page.origin,
`${page.protocol}//${page.hostname.replace(/^www\./i, "")}`,
`${page.protocol}//www.${page.hostname.replace(/^www\./i, "")}`
])];
const timestamps = [...new Set([timestamp, "20190717025956"])];
for (const candidateTimestamp of timestamps) {
for (const origin of origins) {
const assetUrl = new URL("/img/Stoly1.jpg", origin).href;
try {
const file = await downloadAsset(assetUrl, candidateTimestamp, assetDir, assetMap, cssTexts, logs);
logs.push(`Reservation plan restored: ${assetUrl}`);
return file;
} catch {
// Some snapshots contain the reservation page but not its plan image.
}
}
}
logs.push("Reservation plan image is absent from Web Archive; restored reservation details without the missing image.");
return "";
}
async function rewriteCssAssets(css, cssUrl, timestamp, assetDir, assetMap, cssTexts, logs) {
const matches = [...css.matchAll(/url\((?!['"]?data:)(?!['"]?#)['"]?([^'")]+)['"]?\)/gi)];
for (const match of matches) {
const raw = match[1].trim();
const absolute = toAbsoluteAssetUrl(raw, cssUrl);
if (!absolute) continue;
try {
const file = await downloadAsset(absolute, timestamp, assetDir, assetMap, cssTexts, logs);
css = css.replaceAll(raw, file);
} catch (error) {
logs.push(`CSS asset skipped: ${absolute} (${error.message})`);
}
}
return css;
}
function cleanWaybackHtml(html) {
return html
.replace(/<!--\s*BEGIN WAYBACK TOOLBAR INSERT[\s\S]*?END WAYBACK TOOLBAR INSERT\s*-->/gi, "")
.replace(/<script\b[^>]*(?:webarchive|archive_analytics|wombat|wayback|_static\/js)[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<link\b[^>]*(?:webarchive|_static)[^>]*>/gi, "")
.replace(/<div[^>]+id=["']wm-ipp["'][\s\S]*?<\/div>\s*<\/div>\s*<\/div>/gi, "")
.replace(/<div[^>]+id=["']wm-ipp["'][\s\S]*?<\/div>/gi, "")
.replace(/\s*__wm\.[^<]+/gi, "");
}
function patchCasinoMenu(html, label, href) {
if (new RegExp(`href=["'][^"']*${escapeRegex(href)}[^"']*["']`, "i").test(html)) return html;
const link = `<li class="aci-casino-menu-item"><a href="${href}">${escapeHtml(label)}</a></li>`;
const menuPattern = /(<(?:div|nav)[^>]+class=["'][^"']*(?:menu|nav|navigation)[^"']*["'][^>]*>[\s\S]*?<ul[^>]*>[\s\S]*?)(<\/ul>)/i;
if (menuPattern.test(html)) {
return html.replace(menuPattern, `$1${link}$2`);
}
const navPattern = /(<nav\b[\s\S]*?)(<\/nav>)/i;
if (navPattern.test(html)) {
return html.replace(navPattern, (whole, navBody, close) => {
if (/<\/ul>/i.test(navBody)) return `${navBody.replace(/<\/ul>/i, `${link}</ul>`)}${close}`;
return `${navBody}<a class="aci-casino-menu-link" href="${href}">${escapeHtml(label)}</a>${close}`;
});
}
return html;
}
function chunkString(value, size = 7000) {
const chunks = [];
for (let index = 0; index < value.length; index += size) chunks.push(value.slice(index, index + size));
return chunks;
}
function phpString(value) {
return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
}
function phpChunkArray(base64) {
return `[${chunkString(base64).map(phpString).join(",")}]`;
}
async function listFilesRecursive(dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...await listFilesRecursive(full));
else files.push(full);
}
return files;
} catch {
return [];
}
}
function mimeForAssetFile(file, data) {
const ext = path.extname(file).toLowerCase();
const fallback = mimeTypes[ext] || "application/octet-stream";
if (fallback === "application/octet-stream") {
const sample = data.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
if (sample.startsWith("<!doctype html") || sample.startsWith("<html")) return "text/html; charset=utf-8";
}
return fallback;
}
function fixedProjectMenuHtml(config) {
const staticItems = (config.menuItems || []).map((item) => '<li><a href="' + escapeHtml(item.path) + '">' + escapeHtml(item.title) + '</a></li>').join("");
const casinoChildren = (config.casinoPages || []).map((page) => '<li><a href="' + escapeHtml(page.path) + '">' + escapeHtml(page.title) + '</a></li>').join("");
return staticItems + '<li class="aci-casino-accordion"><button class="aci-casino-toggle" type="button" aria-expanded="false">' + escapeHtml(config.casinoMenuLabel || "Casino") + '</button><ul class="aci-casino-submenu">' + casinoChildren + '</ul></li>';
}
function injectStaticCasinoAccordion(html) {
const css = '<style id="aci-casino-menu-style">.aci-casino-accordion{list-style:none}.menu>ul>li>.aci-casino-toggle{display:block;box-sizing:content-box;width:204px;min-height:0;margin:0 auto;padding:10px 0;border:0;border-bottom:1px solid #d2bb87;background:transparent;color:#99042f;font:inherit;line-height:inherit;text-align:left;cursor:pointer;-webkit-appearance:none;appearance:none}.aci-casino-toggle:after{content:" +";float:right}.aci-casino-accordion.is-open>.aci-casino-toggle:after{content:" -"}.aci-casino-submenu{display:none;width:100%;margin:0;padding:0!important}.aci-casino-accordion.is-open>.aci-casino-submenu{display:block}</style>';
const js = '<script id="aci-casino-menu-script">document.addEventListener("click",function(event){var toggle=event.target.closest(".aci-casino-toggle");if(!toggle)return;event.preventDefault();var item=toggle.closest(".aci-casino-accordion");var open=!item.classList.contains("is-open");item.classList.toggle("is-open",open);toggle.setAttribute("aria-expanded",open?"true":"false");});</script>';
if (html.includes('aci-casino-menu-style')) html = html.replace(/<style\b[^>]*id=["']aci-casino-menu-style["'][^>]*>[\s\S]*?<\/style>/i, css);
else html = html.replace(/<\/head>/i, css + '</head>');
if (html.includes('aci-casino-menu-script')) html = html.replace(/<script\b[^>]*id=["']aci-casino-menu-script["'][^>]*>[\s\S]*?<\/script>/i, js);
else html = html.replace(/<\/body>/i, js + '</body>');
return html;
}
function removeUnavailableAssetReference(html, item) {
const raw = escapeRegex(item.raw);
if (item.attr.toLowerCase() === "src") {
html = html.replace(new RegExp(`<img\\b[^>]*\\bsrc=(["'])${raw}\\1[^>]*>`, "gi"), "");
html = html.replace(new RegExp(`<script\\b[^>]*\\bsrc=(["'])${raw}\\1[^>]*>[\\s\\S]*?<\\/script\\s*>`, "gi"), "");
return html.replace(new RegExp(`\\bsrc=(["'])${raw}\\1`, "gi"), "");
}
html = html.replace(new RegExp(`<link\\b[^>]*\\bhref=(["'])${raw}\\1[^>]*>`, "gi"), "");
return html.replace(new RegExp(`\\bhref=(["'])${raw}\\1`, "gi"), 'href="#"');
}
function pruneStaticMenus(html, config) {
const menu = fixedProjectMenuHtml(config);
const replaced = replaceEditableContentRegion(html, "menu", `<ul>${menu}</ul>`);
if (replaced.ok) html = replaced.html;
return injectStaticCasinoAccordion(html);
}
function removeCommercialArtifacts(html, config) {
const allowedHosts = new Set([config.sourceDomain, config.targetDomain]
.filter(Boolean)
.map((host) => String(host).replace(/^https?:\/\//i, "").replace(/^www\./i, "").split("/")[0].toLowerCase()));
html = html
.replace(/<meta\b[^>]*name=["']author["'][^>]*(?:jirout|reklamn[ií]\s+agentura)[^>]*\/?\s*>/gi, "")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, (script) =>
/(?:google-analytics\.com|googletagmanager\.com|GoogleAnalyticsObject|\b_gaq\b)/i.test(script) ? "" : script
)
.replace(/<li\b[^>]*>\s*<p\b[^>]*class=["'][^"']*nadpis[^"']*["'][^>]*>\s*Facebook\s*<\/p>[\s\S]*?<\/li>/gi, "")
.replace(/<p\b[^>]*>[\s\S]*?<\/p>/gi, (paragraph) =>
/(?:jirout|reklamn[ií]\s+agentura)/i.test(paragraph) ? "" : paragraph
)
.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "")
.replace(/<iframe\b[^>]*\/?\s*>/gi, "")
.replace(/href=["'][^"']*mailto:\s*([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})[^"']*["']/gi, 'href="mailto:$1"');
return html.replace(/<a\b([^>]*)\bhref=["'](https?:\/\/[^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (whole, pre, href, post, inner) => {
try {
const host = new URL(href).hostname.replace(/^www\./i, "").toLowerCase();
if ([...allowedHosts].some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) return whole;
} catch {}
return inner;
});
}
function injectReservationDetails(html, config) {
if (!/(?:\?rezervace|Pl[aá]nek rozm[ií]stěn[ií] stolů)/i.test(html)) return html;
html = html.replace(/href=["'](?:[^"']*\?)?rezervace(?:#[^"']*)?["']/gi, 'href="#rezervace-plan"');
if (html.includes('id="rezervace-plan"')) return html;
const image = config._reservationAssetFile
? `<img src="{{ACI_ASSET_URL}}/${escapeHtml(config._reservationAssetFile)}" alt="Rozmístění stolů v restauraci">`
: "";
const css = '<style id="aci-reservation-style">.aci-reservation-plan{display:none;position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.72);padding:24px;overflow:auto}.aci-reservation-plan:target{display:flex;align-items:flex-start;justify-content:center}.aci-reservation-dialog{position:relative;width:min(760px,100%);margin:auto;background:#fff;color:#222;padding:28px;box-sizing:border-box}.aci-reservation-close{position:absolute;right:14px;top:8px;font-size:28px;line-height:1;text-decoration:none}.aci-reservation-dialog img{display:block;max-width:100%;height:auto;margin:18px auto 0}.fotky_menu img{display:inline-block;max-width:100%;object-fit:cover}</style>';
const section = `<section id="rezervace-plan" class="aci-reservation-plan" aria-label="Rezervace"><div class="aci-reservation-dialog"><a class="aci-reservation-close" href="#" aria-label="Zavřít">&times;</a><h2>Rozmístění stolů v restauraci</h2><p>Pro rezervování stolu prosím použijte telefon <a href="tel:+420606045035">606 045 035</a>.</p>${image}</div></section>`;
if (!html.includes('aci-reservation-style')) html = html.replace(/<\/head>/i, css + '</head>');
return html.replace(/<\/body>/i, section + '</body>');
}
function normalizeStaticHtmlForTheme(html, config) {
html = html.replace(/<nav class=["']aci-recovered-nav["']>[\s\S]*?<\/nav>\s*/gi, "");
html = removeCommercialArtifacts(html, config);
html = pruneStaticMenus(html, config);
html = injectReservationDetails(html, config);
return html;
}
async function buildEmbeddedThemeData(themeDir, manifest, config) {
const dataDir = path.join(themeDir, "data");
const staticDir = path.join(dataDir, "static-html");
const assetDir = path.join(dataDir, "archive-assets");
const staticFiles = await listFilesRecursive(staticDir);
const assetFiles = await listFilesRecursive(assetDir);
const staticCases = [];
const assetCases = [];
for (const file of staticFiles) {
const name = path.basename(file);
const html = normalizeStaticHtmlForTheme(await fs.readFile(file, "utf8"), config);
staticCases.push(` case ${phpString(name)}: return acwpb_embedded_decode(${phpChunkArray(Buffer.from(html, "utf8").toString("base64"))});`);
}
// Gutenberg-контент для каждой страницы (для post_content при импорте)
const gutenbergCases = [];
for (const page of manifest.staticPages) {
if (page.gutenbergContent) {
gutenbergCases.push(` case ${phpString(page.file)}: return acwpb_embedded_decode(${phpChunkArray(Buffer.from(page.gutenbergContent, "utf8").toString("base64"))});`);
}
}
for (const file of assetFiles) {
const name = path.basename(file);
const data = await fs.readFile(file);
const mime = mimeForAssetFile(name, data);
assetCases.push(` case ${phpString(name)}: return ['mime' => ${phpString(mime)}, 'body' => acwpb_embedded_decode(${phpChunkArray(data.toString("base64"))})];`);
}
return {
manifestChunks: phpChunkArray(Buffer.from(JSON.stringify(manifest, null, 2), "utf8").toString("base64")),
staticCases: staticCases.join("\n"),
gutenbergCases: gutenbergCases.join("\n"),
assetCases: assetCases.join("\n")
};
}
async function writePlugin(pluginDir, manifest) {
const php = `<?php
/**
* Plugin Name: Archive Casino Importer
* Description: Imports recovered static archive pages and editable Gutenberg Casino pages.
* Version: 0.1.0
*/
if (!defined('ABSPATH')) { exit; }
define('ACI_PLUGIN_FILE', __FILE__);
define('ACI_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('ACI_PLUGIN_URL', plugin_dir_url(__FILE__));
function aci_manifest() {
static $manifest = null;
if ($manifest === null) {
$file = ACI_PLUGIN_DIR . 'data/pages.json';
if (!file_exists($file)) {
$manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]];
return $manifest;
}
$json = file_get_contents($file);
$manifest = json_decode($json, true);
if (!is_array($manifest)) {
$manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]];
}
if (!isset($manifest['staticPages']) || !is_array($manifest['staticPages'])) {
$manifest['staticPages'] = [];
}
if (!isset($manifest['casino']) || !is_array($manifest['casino'])) {
$manifest['casino'] = ['menuLabel' => 'Casino', 'pages' => []];
}
if (!isset($manifest['casino']['pages']) || !is_array($manifest['casino']['pages'])) {
$manifest['casino']['pages'] = [];
}
if (empty($manifest['casino']['menuLabel'])) {
$manifest['casino']['menuLabel'] = 'Casino';
}
}
return $manifest;
}
function aci_static_html($file) {
$path = ACI_PLUGIN_DIR . 'data/static-html/' . basename($file);
if (!file_exists($path)) { return ''; }
$html = file_get_contents($path);
$html = str_replace('{{ACI_ASSET_URL}}', esc_url(ACI_PLUGIN_URL . 'data/archive-assets'), $html);
$html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html);
return $html;
}
function aci_template_redirect() {
if (is_admin()) { return; }
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$path = '/' . trim($path, '/') . '/';
if ($path === '//') { $path = '/'; }
$manifest = aci_manifest();
foreach ($manifest['staticPages'] as $page) {
if ($page['path'] === $path) {
status_header(200);
echo aci_static_html($page['file']);
exit;
}
}
}
add_action('template_redirect', 'aci_template_redirect', 0);
function aci_admin_menu() {
add_management_page('Archive Casino Importer', 'Archive Casino Importer', 'manage_options', 'archive-casino-importer', 'aci_admin_page');
}
add_action('admin_menu', 'aci_admin_menu');
function aci_admin_page() {
if (!current_user_can('manage_options')) { return; }
$message = '';
if (isset($_POST['aci_import']) && check_admin_referer('aci_import')) {
$message = aci_run_import();
}
$manifest = aci_manifest();
echo '<div class="wrap"><h1>Archive Casino Importer</h1>';
if ($message) { echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>'; }
if (!count($manifest['staticPages']) && !count($manifest['casino']['pages'])) {
echo '<div class="notice notice-error"><p>Import manifest is empty or missing. Rebuild and reinstall the importer ZIP.</p></div>';
}
echo '<p>Static pages: ' . intval(count($manifest['staticPages'])) . '. Gutenberg Casino pages: ' . intval(count($manifest['casino']['pages'])) . '.</p>';
echo '<form method="post">';
wp_nonce_field('aci_import');
submit_button('Import pages and menu', 'primary', 'aci_import');
echo '</form></div>';
}
function aci_run_import() {
$manifest = aci_manifest();
if (!count($manifest['staticPages']) && !count($manifest['casino']['pages'])) {
return 'Import manifest is empty. Nothing was imported.';
}
$menu_id = wp_create_nav_menu('Main Menu');
if (is_wp_error($menu_id)) {
$menu = wp_get_nav_menu_object('Main Menu');
$menu_id = $menu ? $menu->term_id : 0;
}
foreach ($manifest['staticPages'] as $page) {
$post_id = aci_upsert_page($page['title'], sanitize_title($page['title']), '<!-- Static archive page. Rendered by Archive Casino Importer. -->');
update_post_meta($post_id, '_aci_static_file', $page['file']);
if ($page['path'] === '/') {
update_option('show_on_front', 'page');
update_option('page_on_front', $post_id);
}
if ($menu_id) {
wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $page['title'],
'menu-item-url' => home_url($page['path']),
'menu-item-status' => 'publish'
]);
}
}
$casino_root_id = 0;
foreach ($manifest['casino']['pages'] as $index => $page) {
$content = aci_casino_page_content($page['title'], $index);
$post_id = aci_upsert_page($page['title'], $page['slug'], $content);
if ($index === 0) { $casino_root_id = $post_id; }
if ($index > 0 && $casino_root_id && strpos($page['path'], '/casino/') === 0) {
wp_update_post(['ID' => $post_id, 'post_parent' => $casino_root_id]);
}
}
if ($menu_id) {
wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $manifest['casino']['menuLabel'],
'menu-item-object' => 'page',
'menu-item-object-id' => $casino_root_id,
'menu-item-type' => 'post_type',
'menu-item-status' => 'publish'
]);
$locations = get_theme_mod('nav_menu_locations', []);
$locations['primary'] = $menu_id;
set_theme_mod('nav_menu_locations', $locations);
}
flush_rewrite_rules();
return 'Import complete.';
}
function aci_upsert_page($title, $slug, $content) {
$existing = get_page_by_path($slug);
$post = [
'post_title' => $title,
'post_name' => $slug,
'post_content' => $content,
'post_status' => 'publish',
'post_type' => 'page'
];
if ($existing) {
$post['ID'] = $existing->ID;
wp_update_post($post);
return $existing->ID;
}
return wp_insert_post($post);
}
function aci_casino_page_content($title, $index) {
$title = esc_html($title);
return '<!-- wp:group {"className":"casino-hero","layout":{"type":"constrained"}} --><div class="wp-block-group casino-hero"><!-- wp:heading {"level":1} --><h1>' . $title . '</h1><!-- /wp:heading --><!-- wp:paragraph --><p>Editable casino content section. Replace this starter copy with your article, review, bonuses, images, and editorial notes.</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Primary CTA</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group --><!-- wp:columns {"className":"casino-layout"} --><div class="wp-block-columns casino-layout"><!-- wp:column --><div class="wp-block-column"><!-- wp:heading {"level":2} --><h2>Overview</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Add article text, screenshots, comparison notes, and contextual links here.</p><!-- /wp:paragraph --><!-- wp:image --><figure class="wp-block-image"><img alt=""/></figure><!-- /wp:image --></div><!-- /wp:column --><!-- wp:column --><div class="wp-block-column"><!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Card</h3><!-- /wp:heading --><!-- wp:list --><ul><li>Bonus details</li><li>Payment notes</li><li>Editorial checks</li></ul><!-- /wp:list --></div><!-- /wp:group --></div><!-- /wp:column --></div><!-- /wp:columns --><!-- wp:heading {"level":2} --><h2>FAQ</h2><!-- /wp:heading --><!-- wp:details --><details class="wp-block-details"><summary>Question</summary><p>Answer text.</p></details><!-- /wp:details -->';
}
function aci_register_patterns() {
if (!function_exists('register_block_pattern')) { return; }
if (function_exists('register_block_pattern_category')) {
register_block_pattern_category('archive-casino', ['label' => 'Archive Casino']);
}
register_block_pattern('archive-casino/review-card', [
'title' => 'Casino Review Card',
'categories' => ['archive-casino'],
'content' => '<!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Name</h3><!-- /wp:heading --><!-- wp:paragraph --><p>Short editable review summary.</p><!-- /wp:paragraph --><!-- wp:list --><ul><li>Pros item</li><li>Payment note</li><li>Bonus condition</li></ul><!-- /wp:list --></div><!-- /wp:group -->'
]);
}
add_action('init', 'aci_register_patterns');
`;
await fs.writeFile(path.join(pluginDir, "archive-casino-importer.php"), php, "utf8");
}
async function writeTheme(themeDir, config, manifest) {
const staticDir = path.join(themeDir, "data", "static-html");
const archiveAssetDir = path.join(themeDir, "data", "archive-assets");
for (const page of manifest.staticPages) {
if (!page.file) throw new Error(`Static page ${page.path || page.title || "unknown"} has no HTML file.`);
const staticFile = path.join(staticDir, path.basename(page.file));
try {
const html = await fs.readFile(staticFile, "utf8");
let normalizedHtml = normalizeStaticHtmlForTheme(html, config);
const marked = ensureEditableContentMarkers(normalizedHtml, page.contentSelector);
if (!marked.ok) throw new Error(marked.error);
normalizedHtml = marked.html;
const markedFooter = ensureEditableFooterMarkers(normalizedHtml);
if (!markedFooter.ok) throw new Error(markedFooter.error);
normalizedHtml = markedFooter.html;
if (!manifest.footer?.html) {
manifest.footer = { html: markedFooter.content };
}
validateFullStaticDocument(normalizedHtml, page.path || page.file);
validateNoUnresolvedAssetReferences(normalizedHtml, page.path || page.file);
await validateStaticAssetReferences(normalizedHtml, archiveAssetDir, page.path || page.file);
page.embeddedHtmlBase64 = Buffer.from(normalizedHtml, "utf8").toString("base64");
await fs.writeFile(staticFile, normalizedHtml, "utf8");
} catch (error) {
throw new Error(`Cannot package static page ${page.path || page.file}: ${error.message}`);
}
}
// Сохранить gutenberg-контент в отдельные файлы (НЕ embedded base64)
const gutDir = path.join(themeDir, "data", "gutenberg");
await fs.mkdir(gutDir, { recursive: true });
for (const page of manifest.staticPages) {
if (page.gutenbergContent && page.file) {
await fs.writeFile(path.join(gutDir, page.file), page.gutenbergContent, "utf8");
}
}
await fs.writeFile(path.join(themeDir, "data", "pages.json"), JSON.stringify(manifest, null, 2), "utf8");
const designAssets = manifest.designAssets || {};
const logoAsset = path.basename(designAssets.logo || "");
const heroAsset = path.basename(designAssets.hero || "");
const sidebarHeaderAsset = path.basename(designAssets.sidebarHeader || "");
const effectivePrimary = /^#f{3,6}$/i.test(manifest.design.primary || "")
? (manifest.design.accent || "#990033")
: manifest.design.primary;
const style = `/*
Theme Name: ${config.themeName}
Author: Archive Casino WP Builder
Version: 1.2.0
Text Domain: archive-casino-embedded
*/
:root {
--aci-primary: ${manifest.design.primary};
--aci-accent: ${manifest.design.accent};
--aci-bg: ${manifest.design.background};
--aci-text: ${manifest.design.text};
--aci-font: ${manifest.design.font};
}
body {
margin: 0;
background-color: var(--aci-bg);
color: var(--aci-text);
font-family: var(--aci-font);
line-height: 1.6;
}
a { color: var(--aci-primary); }
.site-header, .site-footer {
border-bottom: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent);
padding: 18px max(24px, calc((100vw - 1120px) / 2));
}
.site-footer { border-top: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent); border-bottom: 0; margin-top: 48px; }
.site-brand { font-weight: 700; color: var(--aci-text); text-decoration: none; }
.site-nav ul { display: flex; flex-wrap: wrap; gap: 18px; list-style: none; margin: 14px 0 0; padding: 0; }
.site-nav a { text-decoration: none; font-weight: 600; }
/* Casino accordion submenu */
.site-nav li { position: relative; }
.site-nav .sub-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
flex-direction: column;
gap: 6px;
background: var(--aci-bg);
border: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent);
border-radius: 6px;
padding: 8px 0;
margin: 4px 0 0;
min-width: 200px;
z-index: 100;
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
}
.site-nav li:hover > .sub-menu,
.site-nav li:focus-within > .sub-menu,
.site-nav li.menu-item-has-children.is-open > .sub-menu {
display: flex;
}
.site-nav .sub-menu li { white-space: nowrap; }
.site-nav .sub-menu a { display: block; padding: 6px 16px; font-weight: 500; }
.site-nav .sub-menu a:hover { background: color-mix(in srgb, var(--aci-primary) 8%, transparent); }
.site-nav .menu-item-has-children > a::after { content: " ▾"; font-size: 0.8em; opacity: 0.7; }
.site-main { max-width: 1120px; margin: 0 auto; padding: 36px 24px; }
@media (max-width: 760px) {
.site-nav ul { align-items: flex-start; flex-direction: column; gap: 8px; }
.site-nav .sub-menu { position: static; box-shadow: none; margin: 4px 0 0 12px; }
}
/* === Casino-страницы в стиле сайта === */
.entry-content, article.page { max-width: 940px; margin: 0 auto; padding: 24px 20px; }
.entry-content h1 { color: var(--aci-primary); font-size: 1.5rem; margin: 0 0 16px; }
.entry-content h2 { color: var(--aci-primary); margin: 28px 0 12px; }
.entry-content h3 { color: var(--aci-accent); margin: 20px 0 8px; }
.entry-content a { color: var(--aci-text); text-decoration: underline; }
.casino-hero {
background: color-mix(in srgb, var(--aci-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--aci-primary) 25%, transparent);
border-radius: 8px;
padding: 24px;
margin: 0 0 24px;
}
.casino-rating-card {
background: var(--aci-bg);
border: 1px solid color-mix(in srgb, var(--aci-primary) 25%, transparent);
border-radius: 6px;
padding: 20px;
margin: 0 0 24px;
}
.casino-stars { color: var(--aci-primary); font-size: 1.4em; letter-spacing: 3px; }
.casino-pros-cons { gap: 20px; margin: 16px 0; }
.casino-pros, .casino-cons { padding: 16px; border-radius: 4px; }
.casino-pros { background: #e8f5e9; border-left: 4px solid #058d28; }
.casino-cons { background: #fce4ec; border-left: 4px solid var(--aci-primary); }
.casino-pros h3 { color: #058d28; }
.casino-cons h3 { color: var(--aci-primary); }
.wp-block-button__link {
background: var(--aci-primary);
color: #fff;
border-radius: 0;
font-weight: bold;
border: none;
}
.wp-block-button__link:hover { background: var(--aci-accent); }
.entry-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.entry-content th { background: color-mix(in srgb, var(--aci-primary) 20%, transparent); color: var(--aci-primary); padding: 10px; text-align: left; }
.entry-content td { padding: 10px; border-bottom: 1px solid color-mix(in srgb, var(--aci-primary) 15%, transparent); }
.entry-content figure img,
.entry-content .wp-block-image img { border: 3px solid color-mix(in srgb, var(--aci-primary) 25%, transparent); }
.wp-block-details { margin: 8px 0; border: 1px solid color-mix(in srgb, var(--aci-primary) 15%, transparent); border-radius: 4px; padding: 8px 14px; background: color-mix(in srgb, var(--aci-primary) 4%, transparent); }
.wp-block-details summary { font-weight: 600; color: var(--aci-accent); cursor: pointer; }
/* Gutenberg Casino pages inherit the recovered site's visual shell. */
.aci-casino-shell { min-height: 100vh; }
.aci-casino-header { min-height: 112px; }
.aci-casino-header .logo img { display: block; width: 249px; height: 92px; }
.aci-casino-banner { clear: both; width: 971px; height: 270px; margin: 0 auto; overflow: hidden; }
.aci-casino-banner img { display: block; width: 971px; height: 270px; object-fit: cover; }
.aci-casino-nav .sub-menu { display: none; }
.aci-casino-nav .menu-item-has-children.is-open > .sub-menu { display: block; }
.aci-casino-nav .menu-item-has-children > a::after { content: " +"; float: right; }
.aci-casino-nav .menu-item-has-children.is-open > a::after { content: " -"; }
.aci-casino-content { box-sizing: border-box; min-height: 520px; }
.aci-casino-content article.page { max-width: none; margin: 0; padding: 10px 12px 36px; }
.aci-casino-content h1, .aci-casino-content h2 { color: ${effectivePrimary}; }
.aci-casino-footer { box-sizing: border-box; margin: 0; color: #94042d; }
.aci-casino-footer a { color: #94042d; }
@media (max-width: 1000px) {
.aci-casino-shell .main { width: 100%; max-width: 994px; }
.aci-casino-banner { width: calc(100% - 24px); height: auto; aspect-ratio: 971 / 270; }
.aci-casino-banner img { width: 100%; height: 100%; }
.aci-casino-shell .insert_page { box-sizing: border-box; width: 100%; padding: 0 20px; background-color: #fff6e3; }
.aci-casino-shell .insert_page_left { width: 246px; }
.aci-casino-shell .insert_page_right { width: calc(100% - 266px); }
}
@media (max-width: 720px) {
.aci-casino-header { min-height: 104px; }
.aci-casino-header .logo { float: none; padding-left: 12px; }
.aci-casino-header .lang { display: none; }
.aci-casino-banner { display: none; }
.aci-casino-shell .insert_page { padding: 0 12px; }
.aci-casino-shell .insert_page_left,
.aci-casino-shell .insert_page_right { float: none; clear: both; width: 100%; }
.aci-casino-shell .insert_page_left > img { display: none; }
.aci-casino-shell .menu { width: 100%; box-sizing: border-box; }
.aci-casino-shell .menu ul li a:link,
.aci-casino-shell .menu ul li a:hover,
.aci-casino-shell .menu ul li a:visited { box-sizing: border-box; width: calc(100% - 24px); }
.aci-casino-footer { height: auto; min-height: 149px; padding: 24px 28px; }
.aci-casino-footer .texty,
.aci-casino-footer .copy { float: none; clear: both; width: auto; text-align: left; }
}
`;
// Демо-контент Casino (на русском, в стиле сайта) — вставляется в PHP-шаблон.
// Экранируем обратные слеши и $ чтобы PHP получил литеральную строку.
const casinoContentPhp = casinoDemoContent("__TITLE__").replace(/\$/g, "\\\\$").replace(/__TITLE__/g, "' . $title . '");
const embeddedManifestBase64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64");
const functions = `<?php
/**
* Functions for ${config.themeName}
* Self-contained theme with an embedded manifest fallback.
*/
if (!defined('ABSPATH')) { exit; }
function acwpb_theme_manifest() {
static $manifest = null;
if ($manifest !== null) { return $manifest; }
$file = get_stylesheet_directory() . '/data/pages.json';
if (is_readable($file)) {
$json = file_get_contents($file);
$manifest = json_decode($json, true);
}
if (!is_array($manifest)) {
$embedded_json = base64_decode('${embeddedManifestBase64}', true);
$manifest = $embedded_json !== false ? json_decode($embedded_json, true) : null;
}
if (!is_array($manifest)) { $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []], 'menuItems' => []]; }
return $manifest;
}
function acwpb_theme_manifest_debug() {
$file = get_stylesheet_directory() . '/data/pages.json';
$debug = ['path' => $file, 'exists' => file_exists($file), 'readable' => is_readable($file), 'bytes' => 0, 'json_error' => '', 'source' => 'embedded'];
if ($debug['readable']) {
$json = file_get_contents($file);
$debug['bytes'] = strlen((string) $json);
$decoded = json_decode($json, true);
$debug['json_error'] = json_last_error_msg();
if (is_array($decoded)) { $debug['source'] = 'data/pages.json'; }
}
return $debug;
}
function acwpb_theme_setup() {
add_theme_support('title-tag');
add_theme_support('post-thumbnails');
add_theme_support('wp-block-styles');
add_theme_support('editor-styles');
add_theme_support('responsive-embeds');
register_nav_menus(['primary' => 'Primary Menu']);
add_editor_style('style.css');
}
add_action('after_setup_theme', 'acwpb_theme_setup');
function acwpb_theme_assets() {
$manifest = acwpb_theme_manifest();
$assets = $manifest['designAssets'] ?? [];
$base = acwpb_theme_asset_base_url();
$deps = [];
if (!empty($assets['cssReset'])) {
wp_enqueue_style('acwpb-archive-reset', $base . '/' . basename($assets['cssReset']), [], '1.2.0');
$deps[] = 'acwpb-archive-reset';
}
if (!empty($assets['cssMaster'])) {
wp_enqueue_style('acwpb-archive-master', $base . '/' . basename($assets['cssMaster']), $deps, '1.2.0');
$deps[] = 'acwpb-archive-master';
}
wp_enqueue_style('acwpb-theme-style', get_stylesheet_uri(), $deps, '1.2.0');
}
add_action('wp_enqueue_scripts', 'acwpb_theme_assets');
add_action('enqueue_block_editor_assets', 'acwpb_theme_assets');
function acwpb_theme_sync_assets() {
$uploads = wp_upload_dir();
if (!empty($uploads['error']) || empty($uploads['basedir'])) { return false; }
$source = get_stylesheet_directory() . '/data/archive-assets';
$target = rtrim($uploads['basedir'], '/') . '/acwpb-assets';
if (!is_dir($source)) { return false; }
if (!is_dir($target) && !wp_mkdir_p($target)) { return false; }
$files = glob($source . '/*');
if (!is_array($files)) { return false; }
foreach ($files as $source_file) {
if (!is_file($source_file)) { continue; }
$target_file = $target . '/' . basename($source_file);
if (!is_file($target_file) || filesize($target_file) !== filesize($source_file)) {
copy($source_file, $target_file);
}
}
return true;
}
function acwpb_theme_asset_base_url() {
$uploads = wp_upload_dir();
if (empty($uploads['error']) && !empty($uploads['baseurl'])) {
$target = rtrim($uploads['basedir'], '/') . '/acwpb-assets';
if (!is_dir($target)) { acwpb_theme_sync_assets(); }
return rtrim($uploads['baseurl'], '/') . '/acwpb-assets';
}
return home_url('/acwpb-assets');
}
function acwpb_theme_render_static_content($page_id, $html) {
$start = '<!-- ACWPB_CONTENT_START -->';
$end = '<!-- ACWPB_CONTENT_END -->';
if (strpos($html, $start) === false || strpos($html, $end) === false) { return $html; }
$post = get_post($page_id);
if (!$post || $post->post_type !== 'page') { return $html; }
$rendered = apply_filters('the_content', (string) $post->post_content);
$pattern = '#<!-- ACWPB_CONTENT_START -->[\\s\\S]*?<!-- ACWPB_CONTENT_END -->#';
return preg_replace_callback($pattern, function () use ($start, $end, $rendered) {
return $start . "\n" . $rendered . "\n" . $end;
}, $html, 1);
}
function acwpb_theme_default_footer_html() {
$manifest = acwpb_theme_manifest();
return (string) ($manifest['footer']['html'] ?? '<p>Footer content</p>');
}
function acwpb_theme_initialize_footer() {
$missing = '__ACWPB_FOOTER_MISSING__';
if (get_option('acwpb_theme_footer_html', $missing) === $missing) {
update_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html());
}
}
function acwpb_theme_footer_html() {
acwpb_theme_initialize_footer();
$html = (string) get_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html());
$html = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $html);
$html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html);
return $html;
}
function acwpb_theme_render_static_footer($html) {
$start = '<!-- ACWPB_FOOTER_START -->';
$end = '<!-- ACWPB_FOOTER_END -->';
if (strpos($html, $start) === false || strpos($html, $end) === false) { return $html; }
$footer = acwpb_theme_footer_html();
$pattern = '#<!-- ACWPB_FOOTER_START -->[\\s\\S]*?<!-- ACWPB_FOOTER_END -->#';
return preg_replace_callback($pattern, function () use ($start, $end, $footer) {
return $start . "\n" . $footer . "\n" . $end;
}, $html, 1);
}
function acwpb_theme_asset_router() {
if (is_admin()) { return; }
$raw_path = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '/';
if (preg_match('#^/acwpb-assets/(.+)$#', $raw_path, $match)) {
$file = basename($match[1]);
$path = get_stylesheet_directory() . '/data/archive-assets/' . $file;
if (!file_exists($path)) { status_header(404); return; }
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$mimes = ['css'=>'text/css','js'=>'application/javascript','png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg','gif'=>'image/gif','ico'=>'image/x-icon','svg'=>'image/svg+xml','html'=>'text/html; charset=utf-8','woff'=>'font/woff','woff2'=>'font/woff2','ttf'=>'font/ttf'];
$mime = $mimes[$ext] ?? 'application/octet-stream';
if ($ext === 'bin') {
$sample = file_get_contents($path, false, null, 0, 20);
if (stripos(trim($sample), '<!doctype') === 0 || stripos(trim($sample), '<html') === 0) { $mime = 'text/html; charset=utf-8'; }
}
status_header(200);
header('Content-Type: ' . $mime);
if (strpos($mime, 'text/html') === false) { header('Content-Disposition: inline; filename="' . $file . '"'); }
else { header('Content-Disposition: inline'); }
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
if (trim($raw_path, '/') === '') {
$manifest = acwpb_theme_manifest();
$front_path = $manifest['staticPages'][0]['path'] ?? '/';
if ($front_path !== '/') { wp_redirect(home_url($front_path), 301); }
else { return; }
exit;
}
if (is_page()) {
$page_id = get_queried_object_id();
$static_file = $page_id ? get_post_meta($page_id, '_acwpb_static_file', true) : '';
if ($static_file) {
$static_path = get_stylesheet_directory() . '/data/static-html/' . basename($static_file);
$html = is_readable($static_path) ? file_get_contents($static_path) : false;
if ($html === false) {
foreach ((acwpb_theme_manifest()['staticPages'] ?? []) as $page) {
if (($page['file'] ?? '') === $static_file && !empty($page['embeddedHtmlBase64'])) {
$html = base64_decode($page['embeddedHtmlBase64'], true);
break;
}
}
}
if ($html !== false) {
$html = acwpb_theme_render_static_content($page_id, $html);
$html = acwpb_theme_render_static_footer($html);
$html = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $html);
$html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html);
status_header(200);
header('Content-Type: text/html; charset=' . get_bloginfo('charset'));
echo $html;
exit;
}
}
}
}
add_action('template_redirect', 'acwpb_theme_asset_router', 1);
function acwpb_theme_activate() {
$manifest = acwpb_theme_manifest();
acwpb_theme_sync_assets();
acwpb_theme_initialize_footer();
$import_hash = md5(json_encode($manifest) . '|theme-1.2.0');
if (get_option('acwpb_theme_imported') === $import_hash && acwpb_theme_import_is_complete($manifest)) {
return get_option('acwpb_theme_import_report', []);
}
$report = [
'static_expected' => count($manifest['staticPages'] ?? []),
'static_imported' => 0,
'casino_expected' => count($manifest['casino']['pages'] ?? []),
'casino_imported' => 0,
'errors' => [],
];
if (!$report['static_expected']) {
$report['errors'][] = 'Import manifest contains no recovered static pages.';
}
$menu_id = acwpb_theme_menu_id();
acwpb_theme_clear_menu($menu_id);
$static_root_id = 0;
$casino_root_id = acwpb_theme_upsert_path_page(
'Casino',
'/casino/',
'<!-- wp:heading {"level":1} --><h1>Casino</h1><!-- /wp:heading -->',
['preserve_existing' => true]
);
if (is_wp_error($casino_root_id)) {
$report['errors'][] = 'Casino parent failed: ' . $casino_root_id->get_error_message();
$casino_root_id = 0;
}
foreach (($manifest['staticPages'] ?? []) as $page) {
if (empty($page['title']) || empty($page['path']) || empty($page['file'])) { continue; }
$gut_file = get_stylesheet_directory() . '/data/gutenberg/' . $page['file'];
$content = '';
if (is_readable($gut_file)) {
$content = file_get_contents($gut_file);
$content = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $content);
}
if (empty($content) && !empty($page['gutenbergContent'])) {
$content = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $page['gutenbergContent']);
}
if (empty($content)) {
$html_file = get_stylesheet_directory() . '/data/static-html/' . $page['file'];
if (is_readable($html_file)) {
$raw = file_get_contents($html_file);
$content = '<!-- wp:html -->' . $raw . '<!-- /wp:html -->';
}
}
if (empty($content) && !empty($page['embeddedHtmlBase64'])) {
$raw = base64_decode($page['embeddedHtmlBase64'], true);
if ($raw !== false) { $content = '<!-- wp:html -->' . $raw . '<!-- /wp:html -->'; }
}
$post_id = acwpb_theme_upsert_path_page($page['title'], $page['path'], $content, ['preserve_existing' => true]);
if (is_wp_error($post_id) || !$post_id) {
$report['errors'][] = 'Static page failed: ' . $page['path'] . ' - ' . (is_wp_error($post_id) ? $post_id->get_error_message() : 'unknown error');
continue;
}
update_post_meta($post_id, '_acwpb_static_path', $page['path']);
update_post_meta($post_id, '_acwpb_static_file', $page['file']);
$report['static_imported']++;
if ($page['path'] === '/' || $page['path'] === '/cz/') { $static_root_id = $post_id; }
}
foreach (($manifest['casino']['pages'] ?? []) as $page) {
if (empty($page['title']) || empty($page['path'])) { continue; }
$casino_content = !empty($page['gutenbergContent']) ? (string) $page['gutenbergContent'] : acwpb_theme_casino_content($page['title']);
$casino_content = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $casino_content);
$casino_content = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $casino_content);
$post_id = acwpb_theme_upsert_path_page(
$page['title'],
$page['path'],
$casino_content,
['preserve_existing' => true]
);
if (is_wp_error($post_id) || !$post_id) {
$report['errors'][] = 'Casino page failed: ' . $page['path'] . ' - ' . (is_wp_error($post_id) ? $post_id->get_error_message() : 'unknown error');
continue;
}
update_post_meta($post_id, '_acwpb_casino_page', '1');
$report['casino_imported']++;
}
foreach (acwpb_theme_primary_menu_items() as $item) {
acwpb_theme_add_menu_path($menu_id, $item['title'], $item['path']);
}
$casino_pages_menu = $manifest['casino']['pages'] ?? [];
$casino_label_menu = $manifest['casino']['menuLabel'] ?? 'Casino';
$parent_db = wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $casino_label_menu, 'menu-item-url' => '#',
'menu-item-type' => 'custom', 'menu-item-status' => 'publish',
'menu-item-classes' => 'aci-casino-menu-parent menu-item-has-children'
]);
if (!is_wp_error($parent_db) && !empty($parent_db)) {
foreach ($casino_pages_menu as $cp) {
$child = acwpb_theme_page_from_path($cp['path'] ?? '');
if ($child) {
wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $cp['title'] ?? 'Casino', 'menu-item-object' => 'page',
'menu-item-object-id' => $child->ID, 'menu-item-type' => 'post_type',
'menu-item-parent-id' => $parent_db, 'menu-item-status' => 'publish'
]);
}
}
}
$locations = get_theme_mod('nav_menu_locations', []);
$locations['primary'] = $menu_id;
set_theme_mod('nav_menu_locations', $locations);
if ($static_root_id) { update_option('show_on_front', 'page'); update_option('page_on_front', $static_root_id); }
if (!$report['errors'] && acwpb_theme_import_is_complete($manifest)) {
update_option('acwpb_theme_imported', $import_hash);
} else {
delete_option('acwpb_theme_imported');
}
update_option('acwpb_theme_import_report', $report);
flush_rewrite_rules();
return $report;
}
add_action('after_switch_theme', 'acwpb_theme_activate');
function acwpb_theme_maybe_import() {
if ((function_exists('wp_installing') && wp_installing()) || get_transient('acwpb_theme_import_lock')) { return; }
$manifest = acwpb_theme_manifest();
$import_hash = md5(json_encode($manifest) . '|theme-1.2.0');
if (get_option('acwpb_theme_imported') === $import_hash && acwpb_theme_import_is_complete($manifest)) { return; }
set_transient('acwpb_theme_import_lock', 1, 30);
acwpb_theme_activate();
delete_transient('acwpb_theme_import_lock');
}
add_action('init', 'acwpb_theme_maybe_import', 99);
function acwpb_theme_import_notice() {
if (!current_user_can('manage_options')) { return; }
$manifest = acwpb_theme_manifest();
$report = get_option('acwpb_theme_import_report', []);
$expected_static = count($manifest['staticPages'] ?? []);
$expected_casino = count($manifest['casino']['pages'] ?? []);
$complete = acwpb_theme_import_is_complete($manifest);
$screen_link = admin_url('themes.php?page=acwpb-theme-import');
if (!$complete) {
echo '<div class="notice notice-error"><p><strong>Recovered Site Import is incomplete.</strong> Expected ' . intval($expected_static) . ' static pages and ' . intval($expected_casino) . ' Casino pages. <a href="' . esc_url($screen_link) . '">Open import diagnostics</a>.</p></div>';
} elseif (!empty($report)) {
echo '<div class="notice notice-success is-dismissible"><p>Recovered pages are installed: ' . intval($report['static_imported'] ?? $expected_static) . ' static and ' . intval($report['casino_imported'] ?? $expected_casino) . ' Casino pages.</p></div>';
}
}
add_action('admin_notices', 'acwpb_theme_import_notice');
function acwpb_theme_admin_menu() {
add_theme_page('Recovered Site Import', 'Recovered Site Import', 'manage_options', 'acwpb-theme-import', 'acwpb_theme_import_page');
add_theme_page('Footer Content', 'Footer Content', 'manage_options', 'acwpb-footer-content', 'acwpb_theme_footer_page');
}
add_action('admin_menu', 'acwpb_theme_admin_menu');
function acwpb_theme_import_page() {
if (!current_user_can('manage_options')) { return; }
$message = '';
if (isset($_POST['acwpb_theme_import']) && check_admin_referer('acwpb_theme_import')) {
delete_option('acwpb_theme_imported');
$report = acwpb_theme_activate();
$message = empty($report['errors']) ? 'Import completed.' : 'Import finished with errors.';
}
$manifest = acwpb_theme_manifest();
$report = get_option('acwpb_theme_import_report', []);
$debug = acwpb_theme_manifest_debug();
echo '<div class="wrap"><h1>Recovered Site Import</h1>';
if ($message) { echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>'; }
echo '<p>Manifest: <strong>' . intval(count($manifest['staticPages'] ?? [])) . '</strong> static pages and <strong>' . intval(count($manifest['casino']['pages'] ?? [])) . '</strong> Casino pages.</p>';
echo '<p>Manifest source: <code>' . esc_html($debug['source']) . '</code>. File exists: ' . ($debug['exists'] ? 'yes' : 'no') . '; readable: ' . ($debug['readable'] ? 'yes' : 'no') . '; bytes: ' . intval($debug['bytes']) . '; JSON: ' . esc_html($debug['json_error'] ?: 'not read') . '.</p>';
if ($report) {
echo '<p>Last import: ' . intval($report['static_imported'] ?? 0) . ' static pages and ' . intval($report['casino_imported'] ?? 0) . ' Casino pages.</p>';
foreach (($report['errors'] ?? []) as $error) { echo '<div class="notice notice-error inline"><p>' . esc_html($error) . '</p></div>'; }
}
echo '<form method="post">';
wp_nonce_field('acwpb_theme_import');
submit_button('Re-run import', 'primary', 'acwpb_theme_import');
echo '</form></div>';
}
function acwpb_theme_footer_page() {
if (!current_user_can('manage_options')) { return; }
$message = '';
if (isset($_POST['acwpb_footer_save']) && check_admin_referer('acwpb_footer_save')) {
$footer = isset($_POST['acwpb_footer_html']) ? wp_unslash($_POST['acwpb_footer_html']) : '';
update_option('acwpb_theme_footer_html', wp_kses_post($footer));
$message = 'Footer saved. The change is active on recovered and Casino pages.';
} elseif (isset($_POST['acwpb_footer_restore']) && check_admin_referer('acwpb_footer_restore')) {
update_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html());
$message = 'Archived footer restored.';
}
acwpb_theme_initialize_footer();
$footer = (string) get_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html());
echo '<div class="wrap"><h1>Footer Content</h1>';
if ($message) { echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($message) . '</p></div>'; }
echo '<form method="post">';
wp_nonce_field('acwpb_footer_save');
wp_editor($footer, 'acwpb_footer_html', ['textarea_name' => 'acwpb_footer_html', 'media_buttons' => true, 'textarea_rows' => 14]);
submit_button('Save footer', 'primary', 'acwpb_footer_save');
echo '</form><hr><form method="post">';
wp_nonce_field('acwpb_footer_restore');
submit_button('Restore archived footer', 'secondary', 'acwpb_footer_restore', false);
echo '</form></div>';
}
function acwpb_theme_slug_from_path($path) {
$slug = trim($path, '/');
if ($slug === '') { return 'home'; }
return str_replace('/', '-', $slug);
}
function acwpb_theme_normalize_path($path) {
$path = parse_url((string) $path, PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', trim((string) $path, '/'))));
$segments = array_map('sanitize_title', $segments);
return implode('/', array_filter($segments));
}
function acwpb_theme_page_from_path($path) {
$normalized = acwpb_theme_normalize_path($path);
if ($normalized === '') { return null; }
return get_page_by_path($normalized, OBJECT, 'page');
}
function acwpb_theme_upsert_path_page($title, $path, $content, $args = []) {
$normalized = acwpb_theme_normalize_path($path);
if ($normalized === '') { return new WP_Error('acwpb_empty_path', 'Page path is empty.'); }
$segments = explode('/', $normalized);
$parent_id = 0;
$current = [];
foreach ($segments as $index => $segment) {
$current[] = $segment;
$is_last = $index === count($segments) - 1;
$existing = get_page_by_path(implode('/', $current), OBJECT, 'page');
if ($existing && !$is_last) {
$parent_id = (int) $existing->ID;
continue;
}
$page_title = $is_last ? $title : ucwords(str_replace(['-', '_'], ' ', $segment));
$page_content = $is_last ? $content : '';
$post = [
'post_title' => $page_title,
'post_name' => $segment,
'post_parent' => $parent_id,
'post_status' => 'publish',
'post_type' => 'page',
];
$preserve = !empty($args['preserve_existing']);
if (!$existing || !$preserve || trim((string) $existing->post_content) === '') {
$post['post_content'] = $page_content;
}
if ($existing) {
$post['ID'] = $existing->ID;
$page_id = wp_update_post($post, true);
} else {
$page_id = wp_insert_post($post, true);
}
if (is_wp_error($page_id) || !$page_id) { return $page_id; }
$parent_id = (int) $page_id;
}
return $parent_id;
}
function acwpb_theme_import_is_complete($manifest) {
$static_pages = $manifest['staticPages'] ?? [];
if (!count($static_pages)) { return false; }
foreach ($static_pages as $page) {
if (empty($page['path']) || !acwpb_theme_page_from_path($page['path'])) { return false; }
}
foreach (($manifest['casino']['pages'] ?? []) as $page) {
if (empty($page['path']) || !acwpb_theme_page_from_path($page['path'])) { return false; }
}
return acwpb_theme_menu_is_complete($manifest);
}
function acwpb_theme_menu_is_complete($manifest) {
$locations = get_theme_mod('nav_menu_locations', []);
$menu_id = isset($locations['primary']) ? (int) $locations['primary'] : 0;
if (!$menu_id) { return false; }
$items = wp_get_nav_menu_items($menu_id);
if (!is_array($items)) { return false; }
$static_items = $manifest['menuItems'] ?? [];
$casino_pages = $manifest['casino']['pages'] ?? [];
if (count($items) !== count($static_items) + count($casino_pages) + 1) { return false; }
$casino_label = $manifest['casino']['menuLabel'] ?? 'Casino';
$casino_parent = null;
$top_titles = [];
foreach ($items as $item) {
$parent_id = (int) ($item->menu_item_parent ?? 0);
if ($parent_id === 0) { $top_titles[] = (string) ($item->title ?? ''); }
if ($parent_id === 0 && (string) ($item->title ?? '') === $casino_label) { $casino_parent = $item; }
}
if (!$casino_parent) { return false; }
foreach ($static_items as $expected) {
if (!in_array((string) ($expected['title'] ?? ''), $top_titles, true)) { return false; }
}
$child_titles = [];
foreach ($items as $item) {
if ((int) ($item->menu_item_parent ?? 0) === (int) $casino_parent->ID) {
$child_titles[] = (string) ($item->title ?? '');
}
}
if (count($child_titles) !== count($casino_pages)) { return false; }
foreach ($casino_pages as $expected) {
if (!in_array((string) ($expected['title'] ?? ''), $child_titles, true)) { return false; }
}
return true;
}
function acwpb_theme_primary_menu_items() {
$manifest = acwpb_theme_manifest();
$items = [];
foreach (($manifest['menuItems'] ?? []) as $mi) {
if (!empty($mi['title']) && !empty($mi['path'])) { $items[] = ['title' => $mi['title'], 'path' => $mi['path']]; }
}
return $items;
}
function acwpb_theme_clear_menu($menu_id) {
if (!$menu_id) { return; }
$items = wp_get_nav_menu_items($menu_id);
if (is_array($items)) { foreach ($items as $item) { if (!empty($item->ID)) { wp_delete_post($item->ID, true); } } }
}
function acwpb_theme_menu_id() {
$menu = wp_get_nav_menu_object('Main Menu');
if ($menu) { return $menu->term_id; }
$created = wp_create_nav_menu('Main Menu');
return is_wp_error($created) ? 0 : $created;
}
function acwpb_theme_add_menu_url($menu_id, $title, $url) {
if (!$menu_id) { return; }
$items = wp_get_nav_menu_items($menu_id);
if (is_array($items)) {
foreach ($items as $item) {
if (!empty($item->url) && untrailingslashit($item->url) === untrailingslashit($url)) { return; }
}
}
wp_update_nav_menu_item($menu_id, 0, ['menu-item-title' => $title, 'menu-item-url' => $url, 'menu-item-status' => 'publish']);
}
function acwpb_theme_add_menu_path($menu_id, $title, $path) {
if (!$menu_id) { return; }
$page = acwpb_theme_page_from_path($path);
if ($page) {
wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $title,
'menu-item-object' => 'page',
'menu-item-object-id' => $page->ID,
'menu-item-type' => 'post_type',
'menu-item-status' => 'publish',
]);
return;
}
acwpb_theme_add_menu_url($menu_id, $title, home_url($path));
}
function acwpb_theme_upsert_page($title, $slug, $content) {
$slug = sanitize_title($slug);
$existing = get_page_by_path($slug, OBJECT, 'page');
if (!$existing) {
$found = get_posts(['post_type' => 'page', 'post_status' => 'any', 'name' => $slug, 'numberposts' => 1]);
$existing = is_array($found) && count($found) ? $found[0] : null;
}
$post = ['post_title' => $title, 'post_name' => $slug, 'post_content' => $content, 'post_status' => 'publish', 'post_type' => 'page'];
if ($existing) { $post['ID'] = $existing->ID; return wp_update_post($post, true); }
return wp_insert_post($post, true);
}
function acwpb_theme_casino_content($title) {
$title = esc_html($title);
return '${casinoContentPhp}';
}
function acwpb_theme_register_patterns() {
if (!function_exists('register_block_pattern')) { return; }
if (function_exists('register_block_pattern_category')) {
register_block_pattern_category('archive-casino', ['label' => 'Archive Casino']);
}
}
add_action('init', 'acwpb_theme_register_patterns');
`;
const header = `<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<?php $aci_asset_base = acwpb_theme_asset_base_url(); ?>
<div class="top aci-casino-shell">
<div class="main">
<header class="aci-casino-header">
<div class="logo">
<a href="<?php echo esc_url(home_url('/cz/')); ?>" aria-label="<?php echo esc_attr(get_bloginfo('name')); ?>">
${logoAsset ? `<img src="<?php echo esc_url($aci_asset_base . '/${logoAsset}'); ?>" alt="<?php echo esc_attr(get_bloginfo('name')); ?>">` : `<?php bloginfo('name'); ?>`}
</a>
</div>
<div class="lang"><ul><li><a class="select" href="<?php echo esc_url(home_url('/cz/')); ?>">cz</a></li></ul></div>
<div class="cleaner"></div>
</header>
${heroAsset ? `<div class="aci-casino-banner"><img src="<?php echo esc_url($aci_asset_base . '/${heroAsset}'); ?>" alt=""></div>` : ``}
<div class="insert_page aci-casino-layout">
<aside class="insert_page_left">
${sidebarHeaderAsset ? `<img src="<?php echo esc_url($aci_asset_base . '/${sidebarHeaderAsset}'); ?>" alt="Restaurace">` : ``}
<nav class="menu aci-casino-nav" aria-label="Primary navigation">
<?php wp_nav_menu(['theme_location' => 'primary', 'container' => false, 'menu_class' => 'aci-casino-wp-menu', 'fallback_cb' => false]); ?>
</nav>
<div class="insert_left_page_footer">&nbsp;</div>
</aside>
<main class="insert_page_right aci-casino-content">
`;
const footer = ` </main>
<div class="cleaner"></div>
</div>
<footer class="paticka aci-casino-footer">
<?php echo acwpb_theme_footer_html(); ?>
</footer>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.aci-casino-nav .menu-item-has-children > a[href="#"]').forEach(function (link) {
link.setAttribute('aria-expanded', 'false');
link.setAttribute('role', 'button');
});
});
document.addEventListener('click', function (event) {
var link = event.target.closest('.aci-casino-nav .menu-item-has-children > a[href="#"]');
if (!link) { return; }
event.preventDefault();
var item = link.closest('.menu-item-has-children');
var willOpen = !item.classList.contains('is-open');
document.querySelectorAll('.aci-casino-nav .menu-item-has-children.is-open').forEach(function (openItem) {
if (openItem !== item) {
openItem.classList.remove('is-open');
var openLink = openItem.querySelector(':scope > a');
if (openLink) { openLink.setAttribute('aria-expanded', 'false'); }
}
});
item.classList.toggle('is-open', willOpen);
link.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
</script>
<?php wp_footer(); ?>
</body>
</html>
`;
const page = `<?php get_header(); ?>
<?php while (have_posts()) : the_post(); ?>
<article <?php post_class('entry-content'); ?>>
<?php the_content(); ?>
</article>
<?php endwhile; ?>
<?php get_footer(); ?>
`;
const finalStyle = manifest.aiCustomCss
? style + `\n/* AI Review approved customizations */\n${manifest.aiCustomCss}\n`
: style;
await fs.writeFile(path.join(themeDir, "style.css"), finalStyle, "utf8");
await fs.writeFile(path.join(themeDir, "functions.php"), functions, "utf8");
await fs.writeFile(path.join(themeDir, "header.php"), header, "utf8");
await fs.writeFile(path.join(themeDir, "footer.php"), footer, "utf8");
await fs.writeFile(path.join(themeDir, "page.php"), page, "utf8");
await fs.writeFile(path.join(themeDir, "index.php"), page, "utf8");
await fs.writeFile(path.join(themeDir, "README.txt"), [
config.themeName,
"",
"1. Install the ZIP in Appearance > Themes > Add New > Upload Theme.",
"2. Activate the theme. Recovered static pages and editable Casino pages are created automatically.",
"3. Verify the import under Appearance > Recovered Site Import.",
"4. Edit page content under Pages. WordPress injects it into the protected recovered layout; Casino pages remain fully Gutenberg-editable.",
"5. Edit the global footer under Appearance > Footer Content. It is shared by recovered and Casino pages.",
"",
`Expected recovered pages: ${manifest.staticPages.length}`,
`Expected Casino pages: ${manifest.casino?.pages?.length || 0}`,
].join("\n"), "utf8");
}
// ---------------------------------------------------------------------------
// Генерация демо-контента Casino-страницы (на русском, в стиле сайта).
// Пользователь может удалить и заменить своими статьями.
// ---------------------------------------------------------------------------
function casinoDemoContent(title) {
const t = escapeHtml(title);
return '<!-- wp:group {"className":"casino-hero","layout":{"type":"constrained"}} -->' +
'<div class="wp-block-group casino-hero">' +
'<!-- wp:heading {"level":1} --><h1>' + t + '</h1><!-- /wp:heading -->' +
'<!-- wp:paragraph --><p>Редактируемая страница Casino. Здесь размещается статья с обзором, рейтингом, бонусами и изображениями. Стиль адаптирован под дизайн сайта. Замените этот демо-контент своим.</p><!-- /wp:paragraph -->' +
'<!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Играть →</a></div><!-- /wp:button --></div><!-- /wp:buttons -->' +
'</div><!-- /wp:group -->' +
'<!-- wp:group {"className":"casino-rating-card"} --><div class="wp-block-group casino-rating-card">' +
'<!-- wp:heading {"level":2} --><h2>Рейтинг</h2><!-- /wp:heading -->' +
'<!-- wp:paragraph {"className":"casino-stars"} --><p class="casino-stars">★★★★★</p><!-- /wp:paragraph -->' +
'<!-- wp:columns {"className":"casino-pros-cons"} --><div class="wp-block-columns casino-pros-cons">' +
'<!-- wp:column {"className":"casino-pros"} --><div class="wp-block-column casino-pros">' +
'<!-- wp:heading {"level":3} --><h3>Плюсы</h3><!-- /wp:heading -->' +
'<!-- wp:list --><ul><li>Лицензия Curaçao</li><li>Быстрый вывод</li><li>Мобильная версия</li></ul><!-- /wp:list -->' +
'</div><!-- /wp:column -->' +
'<!-- wp:column {"className":"casino-cons"} --><div class="wp-block-column casino-cons">' +
'<!-- wp:heading {"level":3} --><h3>Минусы</h3><!-- /wp:heading -->' +
'<!-- wp:list --><ul><li>Высокий вейджер</li><li>Нет поддержки на чешском</li></ul><!-- /wp:list -->' +
'</div><!-- /wp:column --></div><!-- /wp:columns -->' +
'</div><!-- /wp:group -->' +
'<!-- wp:heading {"level":2} --><h2>Обзор</h2><!-- /wp:heading -->' +
'<!-- wp:paragraph --><p>Описание казино: ассортимент игр, провайдеры, лицензия, методы оплаты и вывода. Текст редактируется в Gutenberg — кликни на блок, чтобы изменить.</p><!-- /wp:paragraph -->' +
'<!-- wp:image --><figure class="wp-block-image"><img alt="Скриншот казино"/></figure><!-- /wp:image -->' +
'<!-- wp:heading {"level":2} --><h2>Бонусы</h2><!-- /wp:heading -->' +
'<!-- wp:table --><figure class="wp-block-table"><table><thead><tr><th>Бонус</th><th>Сумма</th><th>Вейджер</th></tr></thead>' +
'<tbody><tr><td>Приветственный</td><td>100% до $500</td><td>x35</td></tr>' +
'<tr><td>Без депозита</td><td>$20</td><td>x50</td></tr>' +
'<tr><td>Фриспины</td><td>200 FS</td><td>x40</td></tr></tbody></table></figure><!-- /wp:table -->' +
'<!-- wp:heading {"level":2} --><h2>FAQ</h2><!-- /wp:heading -->' +
'<!-- wp:details --><details class="wp-block-details"><summary>Лицензировано ли казино?</summary><p>Да, лицензия Curaçao eGaming.</p></details><!-- /wp:details -->' +
'<!-- wp:details --><details class="wp-block-details"><summary>Какие методы вывода доступны?</summary><p>Банковские карты, Neteller, Skrill, криптовалюты.</p></details><!-- /wp:details -->';
}
// ===========================================================================
// ФАЗА ОЧИСТКИ: авто-удаление битых (404) и внешних ссылок
// ===========================================================================
/**
* Очистка HTML от битых ссылок (404) и внешних ссылок на чужие домены.
* - <a href="чужой_домен">текст</a> → снимается обёртка <a>, остаётся текст
* - <img src="чужой_домен"> → удаляется полностью
* - <iframe src="чужой_домен"> → удаляется полностью
* - 404-е ссылки — то же самое
* Возвращает { cleanedHtml, removed: [{type, url, reason, action}] }.
*/
async function cleanExternalLinks(html, sourceDomain, progressLogs) {
const removed = [];
const sourceHost = sourceDomain.replace(/^www\./, "").toLowerCase();
// Собрать все href/src с http(s)
const urlPattern = /\b(href|src)=["'](https?:\/\/[^"']+)["']/gi;
const matches = [...html.matchAll(urlPattern)];
const toCheck = [];
const seen = new Set();
for (const m of matches) {
const url = m[2];
if (seen.has(url)) continue;
seen.add(url);
if (url.includes("web.archive.org")) continue;
toCheck.push({ url, attr: m[1] });
}
// Проверить доступность и домен (батчами по 8)
const broken = new Set();
const external = new Set();
if (progressLogs) progressLogs.push(`[Clean] проверяю ${toCheck.length} ссылок...`);
const BATCH = 8;
for (let i = 0; i < toCheck.length; i += BATCH) {
const batch = toCheck.slice(i, i + BATCH);
const results = await Promise.allSettled(batch.map(async (item) => {
try {
const host = new URL(item.url).hostname.replace(/^www\./, "").toLowerCase();
// внешний домен?
if (host !== sourceHost && !host.endsWith("." + sourceHost)) {
return { ...item, status: 0, external: true };
}
// внутренний — проверяем HEAD
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);
const r = await fetch(item.url, { method: "HEAD", signal: controller.signal, redirect: "follow" });
clearTimeout(timer);
return { ...item, status: r.status, external: false };
} catch (e) {
return { ...item, status: 0, external: true, unreachable: true };
}
}));
for (const r of results) {
if (r.status !== "fulfilled") continue;
const val = r.value;
if (val.external) {
external.add(val.url);
const host = safeHostname(val.url);
removed.push({ url: val.url, reason: val.unreachable ? "недоступен" : "чужой домен", action: "удалён" });
} else if (val.status === 404) {
broken.add(val.url);
removed.push({ url: val.url, reason: "404", action: "удалён" });
} else if (val.status === 0) {
external.add(val.url);
removed.push({ url: val.url, reason: "timeout/недоступен", action: "удалён" });
}
}
}
const toRemove = new Set([...broken, ...external]);
if (toRemove.size === 0) {
if (progressLogs) progressLogs.push(`[Clean] битых и внешних ссылок не найдено`);
return { cleanedHtml: html, removed: [] };
}
// Применить удаления через DOM-подобный разбор
let cleaned = html;
// 1. Удалить <iframe src="битый"> полностью
cleaned = cleaned.replace(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*>[\s\S]*?<\/iframe>/gi, (whole, src) => {
if (toRemove.has(src)) { return ""; }
return whole;
});
// self-closing iframe
cleaned = cleaned.replace(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*\/?>/gi, (whole, src) => {
if (toRemove.has(src)) { return ""; }
return whole;
});
// 2. Удалить <img src="битый"> полностью
cleaned = cleaned.replace(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*\/?>/gi, (whole, src) => {
if (toRemove.has(src)) { return ""; }
return whole;
});
// 3. Для <a href="битый"> — снять обёртку, оставить содержимое (текст)
cleaned = cleaned.replace(/<a\b([^>]*)\bhref=["']([^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (whole, pre, href, post, inner) => {
if (toRemove.has(href)) { return inner; }
return whole;
});
if (progressLogs) {
progressLogs.push(`[Clean] удалено ссылок: ${toRemove.size} (битых: ${broken.size}, внешних: ${external.size})`);
for (const r of removed.slice(0, 15)) {
progressLogs.push(` - ${r.url.slice(0, 70)} (${r.reason})`);
}
}
return { cleanedHtml: cleaned, removed };
}
function safeHostname(url) {
try { return new URL(url).hostname; } catch { return "?"; }
}
function unwrapGutenbergHtmlBlock(content) {
return String(content || "")
.replace(/^\s*<!--\s*wp:html\s*-->\s*/i, "")
.replace(/\s*<!--\s*\/wp:html\s*-->\s*$/i, "")
.trim();
}
function validateEditableHtmlFragment(content) {
if (/<\/?(?:html|head|body)\b/i.test(content)) {
return "Only the page content area can be edited. Document-level html/head/body tags are protected.";
}
if (/<(?:link|meta|base)\b/i.test(content)) {
return "Stylesheets and document metadata are protected. Edit text and content markup only.";
}
if (/<script\b/i.test(content)) {
return "Scripts are protected and cannot be added in the content editor.";
}
return "";
}
function editableSelectorCandidates(contentSelector) {
const configured = String(contentSelector || "")
.replace(/[.#]/g, " ")
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
return [...new Set([
...configured,
"insert_page_right",
"entry-content",
"main-content",
"main_content",
"post-content",
"post_content",
"page-content",
"content-area",
"site-main",
"content"
])];
}
function locateEditableContentRegion(html, contentSelector) {
for (const cls of editableSelectorCandidates(contentSelector)) {
const openRe = new RegExp(`<((?:div|main|article|section))\\b[^>]*class=["'][^"']*\\b${escapeRegex(cls)}\\b[^"']*["'][^>]*>`, "i");
const open = openRe.exec(html);
if (!open) continue;
const tag = open[1].toLowerCase();
const innerStart = open.index + open[0].length;
const tagRe = new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi");
tagRe.lastIndex = innerStart;
let depth = 1;
let token;
while ((token = tagRe.exec(html)) !== null) {
if (/^<\//.test(token[0])) depth--;
else if (!/\/>$/.test(token[0])) depth++;
if (depth === 0) {
return { innerStart, innerEnd: token.index, selector: cls, tag };
}
}
}
return null;
}
function ensureEditableContentWrapper(html, contentSelector) {
const existing = locateEditableContentRegion(html, contentSelector);
if (existing) return { html, selector: existing.selector };
const heading = /<h[12]\b[^>]*>/i.exec(html);
if (!heading) return { html, selector: contentSelector || "content" };
const openRe = /<(main|article|section|div)\b[^>]*>/gi;
let best = null;
let open;
while ((open = openRe.exec(html)) !== null && open.index < heading.index) {
const region = locateBalancedTagContent(html, open);
if (region && region.innerEnd > heading.index && (!best || open.index > best.open.index)) best = { open, region };
}
if (!best) return { html, selector: contentSelector || "content" };
const selector = "acwpb-detected-content";
const opening = best.open[0];
const updatedOpening = /\bclass=(['"])/i.test(opening)
? opening.replace(/\bclass=(['"])([^'"]*)\1/i, (_whole, quote, classes) => `class=${quote}${classes} ${selector}${quote}`)
: opening.replace(/>$/, ` class="${selector}">`);
return {
html: html.slice(0, best.open.index) + updatedOpening + html.slice(best.open.index + opening.length),
selector
};
}
function replaceEditableContentRegion(fullHtml, contentSelector, newContent) {
const region = locateEditableContentRegion(fullHtml, contentSelector);
if (!region) {
return { ok: false, error: `Protected content wrapper not found (${contentSelector || "automatic selector"}). The full HTML file was not changed.` };
}
return {
ok: true,
html: fullHtml.slice(0, region.innerStart) + "\n" + newContent.trim() + "\n" + fullHtml.slice(region.innerEnd),
selector: region.selector
};
}
const STATIC_CONTENT_START = "<!-- ACWPB_CONTENT_START -->";
const STATIC_CONTENT_END = "<!-- ACWPB_CONTENT_END -->";
const STATIC_FOOTER_START = "<!-- ACWPB_FOOTER_START -->";
const STATIC_FOOTER_END = "<!-- ACWPB_FOOTER_END -->";
function ensureEditableContentMarkers(fullHtml, contentSelector) {
if (fullHtml.includes(STATIC_CONTENT_START) && fullHtml.includes(STATIC_CONTENT_END)) {
return { ok: true, html: fullHtml };
}
const region = locateEditableContentRegion(fullHtml, contentSelector);
if (!region) {
return { ok: false, error: `Editable content wrapper not found (${contentSelector || "automatic selector"}).` };
}
return {
ok: true,
html: fullHtml.slice(0, region.innerStart) + "\n" + STATIC_CONTENT_START + "\n" +
fullHtml.slice(region.innerStart, region.innerEnd).trim() + "\n" + STATIC_CONTENT_END + "\n" +
fullHtml.slice(region.innerEnd),
selector: region.selector
};
}
function locateEditableFooterRegion(html) {
const candidates = ["paticka", "site-footer", "footer"];
for (const cls of candidates) {
const openRe = new RegExp(`<((?:footer|div|section))\\b[^>]*class=["'][^"']*\\b${escapeRegex(cls)}\\b[^"']*["'][^>]*>`, "i");
const open = openRe.exec(html);
if (!open) continue;
const region = locateBalancedTagContent(html, open);
if (region) return { ...region, selector: cls };
}
const semanticFooter = /<footer\b[^>]*>/i.exec(html);
return semanticFooter ? locateBalancedTagContent(html, semanticFooter) : null;
}
function locateBalancedTagContent(html, open) {
const tagMatch = /^<([a-z0-9:-]+)/i.exec(open[0]);
if (!tagMatch) return null;
const tag = tagMatch[1].toLowerCase();
const innerStart = open.index + open[0].length;
const tagRe = new RegExp(`<\\/?${escapeRegex(tag)}\\b[^>]*>`, "gi");
tagRe.lastIndex = innerStart;
let depth = 1;
let token;
while ((token = tagRe.exec(html)) !== null) {
if (/^<\//.test(token[0])) depth--;
else if (!/\/>$/.test(token[0])) depth++;
if (depth === 0) return { innerStart, innerEnd: token.index, tag };
}
return null;
}
function ensureEditableFooterMarkers(fullHtml) {
if (fullHtml.includes(STATIC_FOOTER_START) && fullHtml.includes(STATIC_FOOTER_END)) {
const extracted = extractMarkedRegion(fullHtml, STATIC_FOOTER_START, STATIC_FOOTER_END);
return { ok: true, html: fullHtml, content: extracted || "" };
}
const region = locateEditableFooterRegion(fullHtml);
if (region) {
const content = fullHtml.slice(region.innerStart, region.innerEnd).trim();
return {
ok: true,
html: fullHtml.slice(0, region.innerStart) + "\n" + STATIC_FOOTER_START + "\n" + content + "\n" + STATIC_FOOTER_END + "\n" + fullHtml.slice(region.innerEnd),
content
};
}
const fallback = `<footer class="acwpb-editable-footer">\n${STATIC_FOOTER_START}\n<p>Footer content</p>\n${STATIC_FOOTER_END}\n</footer>`;
if (/<\/body>/i.test(fullHtml)) {
return { ok: true, html: fullHtml.replace(/<\/body>/i, fallback + "\n</body>"), content: "<p>Footer content</p>" };
}
return { ok: false, error: "HTML document has no footer and no closing body tag." };
}
function extractMarkedRegion(html, start, end) {
const startIndex = html.indexOf(start);
const endIndex = html.indexOf(end, startIndex + start.length);
if (startIndex < 0 || endIndex < 0) return null;
return html.slice(startIndex + start.length, endIndex).trim();
}
function extractEditableFooterContent(fullHtml) {
const marked = ensureEditableFooterMarkers(fullHtml);
if (!marked.ok) return marked;
return { ok: true, content: extractMarkedRegion(marked.html, STATIC_FOOTER_START, STATIC_FOOTER_END) || "" };
}
function replaceEditableFooterRegion(fullHtml, newContent) {
const marked = ensureEditableFooterMarkers(fullHtml);
if (!marked.ok) return marked;
const startIndex = marked.html.indexOf(STATIC_FOOTER_START);
const endIndex = marked.html.indexOf(STATIC_FOOTER_END, startIndex + STATIC_FOOTER_START.length);
return {
ok: true,
html: marked.html.slice(0, startIndex + STATIC_FOOTER_START.length) + "\n" + newContent.trim() + "\n" + marked.html.slice(endIndex)
};
}
// ===========================================================================
// ФАЗА РАЗБОРКИ: splitPageHtml — разбор архивного HTML на части
// ===========================================================================
/**
* Разобрать полный архивный HTML на 4 части:
* headAssets — <link>/<script> из <head> (для wp_enqueue)
* shellHtml — шапка, слайдер, меню (до контентной зоны) → header.php
* contentHtml — КОНТЕНТ страницы → Gutenberg-блоки
* footerHtml — футер (после контентной зоны) → footer.php
*
* Эвристика поиска контентной зоны:
* 1) классы: insert_page_right, content, main-content, article, entry-content
* 2) элемент с наибольшим числом <p>/<h1>-<h6>/<img>
*/
function splitPageHtml(html, contentSelector) {
let bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const body = bodyMatch ? bodyMatch[1] : html;
let headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
const headAssets = headMatch ? extractHeadAssets(headMatch[1]) : { styles: [], scripts: [] };
// поиск контентной зоны
let contentStart = -1, contentEnd = -1, contentOpenTag = "", contentCloseCount = 0;
const selectors = contentSelector
? [contentSelector]
: ["insert_page_right", "content", "main-content", "main_content",
"article", "entry-content", "post-content", "post_content",
"page-content", "content-area", "site-main"];
for (const cls of selectors) {
const re = new RegExp(`<div[^>]*class=["'][^"']*\\b${cls}\\b[^"']*["'][^>]*>`, "i");
const m = body.match(re);
if (m) {
contentStart = body.indexOf(m[0]);
contentOpenTag = m[0];
break;
}
}
// если не найдено по классам — fallback: найти <h1> и взять от него
if (contentStart === -1) {
const h1 = body.match(/<h[12][^>]*>/i);
if (h1) {
contentStart = body.indexOf(h1[0]);
contentOpenTag = "";
}
}
// если совсем ничего — весь body
if (contentStart === -1) {
return {
headAssets,
shellHtml: "",
contentHtml: body,
footerHtml: "",
contentSelector: "(full body)"
};
}
// найти конец контентной зоны: баланс div-ов от contentStart
if (contentOpenTag) {
// найти закрывающий тег для открывающего contentOpenTag
const afterStart = contentStart + contentOpenTag.length;
let depth = 1;
const tagRe = /<\/?div\b[^>]*>/gi;
tagRe.lastIndex = afterStart;
let m2;
while ((m2 = tagRe.exec(body)) !== null) {
if (m2[0].startsWith("</")) {
depth--;
if (depth === 0) {
contentEnd = m2.index + m2[0].length;
break;
}
} else {
depth++;
}
}
if (contentEnd === -1) contentEnd = body.length;
const contentFull = body.slice(contentStart, contentEnd);
// вытащить innerHTML (между открывающим и закрывающим тегом)
const contentHtml = contentFull.slice(contentOpenTag.length, contentFull.length - 6); // -"</div>"
return {
headAssets,
shellHtml: body.slice(0, contentStart),
contentHtml,
footerHtml: body.slice(contentEnd),
contentSelector: contentOpenTag.match(/class=["']([^"']*)["']/i)?.[1] || "(detected)"
};
} else {
// без обёртки (fallback на h1) — контент до конца body
return {
headAssets,
shellHtml: body.slice(0, contentStart),
contentHtml: body.slice(contentStart),
footerHtml: "",
contentSelector: "(h1 fallback)"
};
}
}
function extractHeadAssets(headHtml) {
const styles = [];
const scripts = [];
for (const m of headHtml.matchAll(/<link[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*>/gi)) {
styles.push(m[1]);
}
for (const m of headHtml.matchAll(/<script[^>]*\bsrc=["']([^"']+)["'][^>]*><\/script>/gi)) {
scripts.push(m[1]);
}
return { styles, scripts };
}
// ===========================================================================
// ФАЗА КОНВЕРТАЦИИ: htmlToGutenberg — HTML-фрагмент → Gutenberg-блоки
// ===========================================================================
/**
* Конвертировать контентную зону в ОДИН Custom HTML блок.
* Весь контент (заголовки, абзацы, таблицы, картинки) идёт внутри
* <!-- wp:html -->...<!-- /wp:html --> — это не ломается при редактировании
* в WordPress HTML-редакторе. wp_kses_post не вырезает содержимое wp:html.
*
* Очистка: декодирование сущностей, удаление устаревших атрибутов,
* нормализация пробелов. Без разбиения на блоки — единый HTML-фрагмент.
*/
function htmlToGutenberg(html) {
// декодировать сущности и очистить
html = sanitizeForGutenberg(html);
if (!html.trim()) return "";
// Один Custom HTML блок со всем контентом
return `<!-- wp:html -->\n${html}\n<!-- /wp:html -->`;
}
/**
* Разбить HTML на top-level элементы (теги + текстовые узлы).
*/
function splitTopLevelElements(html) {
const elements = [];
let i = 0;
const tagRe = /<(\w+)(\s[^>]*)?>([\s\S]*?)<\/\1>|<(\w+)(\s[^>]*)?\/?>/g;
let lastEnd = 0;
let m;
while ((m = tagRe.exec(html)) !== null) {
// текст перед тегом
if (m.index > lastEnd) {
const text = html.slice(lastEnd, m.index).trim();
if (text && text !== "&nbsp;") {
elements.push({ type: "text", content: text });
}
}
const tag = m[1] || m[4];
const attrs = m[2] || m[5] || "";
const inner = m[3] !== undefined ? m[3] : "";
elements.push({ type: "tag", tag: tag.toLowerCase(), attrs, inner, raw: m[0] });
lastEnd = m.index + m[0].length;
}
// хвост
if (lastEnd < html.length) {
const text = html.slice(lastEnd).trim();
if (text && text !== "&nbsp;") {
elements.push({ type: "text", content: text });
}
}
return elements;
}
/**
* Очистить HTML от атрибутов/стилей, которые wp_kses_post вырезает.
* Это предотвращает «слёт верстки» при редактировании в Gutenberg.
*
* Что делает:
* - удаляет inline style="" (кроме text-align — он мапится в Gutenberg align)
* - удаляет устаревшие атрибуты таблиц (border, cellpadding, cellspacing)
* - удаляет нестандартные атрибуты (rel="album", allowTransparency, frameborder)
* - нормализует <br> (одиночные остаются, двойные → разрыв абзаца на уровне конвертера)
* - декодирует сущности
*/
function sanitizeForGutenberg(html) {
// декодировать сущности (чешские/латинские)
html = decodeEntities(html);
// Для Custom HTML блока (wp:html) wp_kses_post НЕ применяется —
// поэтому inline style="" можно оставить (сохраняет вёрстку).
// Убираем только откровенный мусор:
// нормализовать <br>
html = html.replace(/<br\s*\/?>/gi, "<br/>");
// убрать лишние пробелы между тегами (не внутри текста)
html = html.replace(/>\s+</g, "> <");
// удалить пустые параграфы
html = html.replace(/<p>\s*<\/p>/gi, "");
// удалить служебные комментарии
html = html.replace(/<!--[^]*?-->/g, "");
return html.trim();
}
function elementToBlock(el) {
if (el.type === "text") {
const cleaned = sanitizeForGutenberg(el.content);
if (!cleaned) return null;
return `<!-- wp:paragraph -->\n<p>${cleaned}</p>\n<!-- /wp:paragraph -->`;
}
switch (el.tag) {
case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": {
const level = parseInt(el.tag[1]);
const text = sanitizeForGutenberg(el.inner);
if (!text) return null;
return `<!-- wp:heading {"level":${level}} -->\n<h${level}>${text}</h${level}>\n<!-- /wp:heading -->`;
}
case "p": {
const align = extractAlign(el.attrs);
// вытащить выравнивание ДО очистки style
let inner = el.inner.trim();
if (!inner || inner.replace(/&nbsp;|\s|<br\/?>/gi, "") === "") {
return `<!-- wp:spacer {"height":"24px"} -->\n<div style="height:24px" aria-hidden="true" class="wp-block-spacer"></div>\n<!-- /wp:spacer -->`;
}
// разбить по двойному <br> на отдельные параграфы
const parts = inner.split(/<br\s*\/?>\s*<br\s*\/?>/i);
if (parts.length > 1) {
return parts.map(p => {
const t = sanitizeForGutenberg(p);
if (!t) return null;
const cls = align ? ` class="has-text-align-${align}"` : "";
const atts = align ? ` {"align":"${align}"}` : "";
return `<!-- wp:paragraph${atts} -->\n<p${cls}>${t}</p>\n<!-- /wp:paragraph -->`;
}).filter(Boolean).join("\n\n");
}
// одиночный <br> внутри → разбиваем тоже (Gutenberg не любит <br> в параграфах)
const brParts = inner.split(/<br\s*\/?>/i);
if (brParts.length > 1) {
return brParts.map(p => {
const t = sanitizeForGutenberg(p);
if (!t) return null;
const cls = align ? ` class="has-text-align-${align}"` : "";
const atts = align ? ` {"align":"${align}"}` : "";
return `<!-- wp:paragraph${atts} -->\n<p${cls}>${t}</p>\n<!-- /wp:paragraph -->`;
}).filter(Boolean).join("\n\n");
}
inner = sanitizeForGutenberg(inner);
const cls = align ? ` class="has-text-align-${align}"` : "";
const atts = align ? ` {"align":"${align}"}` : "";
return `<!-- wp:paragraph${atts} -->\n<p${cls}>${inner}</p>\n<!-- /wp:paragraph -->`;
}
case "table": {
// очистить внутренний HTML таблицы от устаревших атрибутов
const cleanInner = sanitizeForGutenberg(el.inner);
return `<!-- wp:table -->\n<figure class="wp-block-table"><table>${cleanInner}</table></figure>\n<!-- /wp:table -->`;
}
case "ul": {
const cleanInner = sanitizeForGutenberg(el.inner);
return `<!-- wp:list -->\n<ul>${cleanInner}</ul>\n<!-- /wp:list -->`;
}
case "ol": {
const cleanInner = sanitizeForGutenberg(el.inner);
return `<!-- wp:list {"ordered":true} -->\n<ol>${cleanInner}</ol>\n<!-- /wp:list -->`;
}
case "img": {
const src = extractAttr(el.attrs, "src") || "";
const alt = extractAttr(el.attrs, "alt") || "";
const w = extractStyleValue(el.attrs, "width") || extractAttr(el.attrs, "width");
const h = extractStyleValue(el.attrs, "height") || extractAttr(el.attrs, "height");
// width/height через атрибуты (wp_kses_post их разрешает)
const wAttr = w ? ` width="${parseInt(w)}"` : "";
const hAttr = h ? ` height="${parseInt(h)}"` : "";
return `<!-- wp:image -->\n<figure class="wp-block-image"><img src="${src}" alt="${alt}"${wAttr}${hAttr}/></figure>\n<!-- /wp:image -->`;
}
case "a": {
// <a> с <img> внутри → image block с linkDestination
const href = extractAttr(el.attrs, "href") || "";
const imgMatch = el.inner.match(/<img\b[^>]*\/?>/i);
if (imgMatch) {
const imgAttrs = imgMatch[0];
const src = extractAttr(imgAttrs, "src") || "";
const alt = extractAttr(imgAttrs, "alt") || "";
const w = extractStyleValue(imgAttrs, "width") || extractAttr(imgAttrs, "width");
const h = extractStyleValue(imgAttrs, "height") || extractAttr(imgAttrs, "height");
const wAttr = w ? ` width="${parseInt(w)}"` : "";
const hAttr = h ? ` height="${parseInt(h)}"` : "";
return `<!-- wp:image -->\n<figure class="wp-block-image"><a href="${href}"><img src="${src}" alt="${alt}"${wAttr}${hAttr}/></a></figure>\n<!-- /wp:image -->`;
}
// обычная ссылка → paragraph
const text = sanitizeForGutenberg(el.inner);
if (!text) return null;
return `<!-- wp:paragraph -->\n<p><a href="${href}">${text}</a></p>\n<!-- /wp:paragraph -->`;
}
case "div": {
const cls = extractAttr(el.attrs, "class") || "";
// cleaner → удалить
if (cls.includes("cleaner")) return null;
// div с классом → group
const innerBlocks = htmlToGutenberg(el.inner);
if (!innerBlocks.trim()) return null;
const className = cls.split(/\s+/).filter(Boolean).join(" ");
const atts = className ? ` {"className":"${className}"}` : "";
return `<!-- wp:group${atts} -->\n<div class="wp-block-group${className ? " " + className : ""}">\n${innerBlocks}\n</div>\n<!-- /wp:group -->`;
}
case "figure": {
const innerBlocks = htmlToGutenberg(el.inner);
if (!innerBlocks.trim()) return null;
return `<!-- wp:group -->\n<figure class="wp-block-group">\n${innerBlocks}\n</figure>\n<!-- /wp:group -->`;
}
case "blockquote": {
const cleanInner = sanitizeForGutenberg(el.inner);
return `<!-- wp:quote -->\n<blockquote class="wp-block-quote">${cleanInner}</blockquote>\n<!-- /wp:quote -->`;
}
case "hr": {
return `<!-- wp:separator -->\n<hr class="wp-block-separator"/>\n<!-- /wp:separator -->`;
}
default: {
// нераспознанное → очистить и в wp:html
const content = sanitizeForGutenberg(el.raw || el.content || "");
if (!content) return null;
return `<!-- wp:html -->\n${content}\n<!-- /wp:html -->`;
}
}
}
function extractAlign(attrs) {
const m = attrs.match(/style=["'][^"']*\btext-align:\s*(left|right|center)\b[^"']*["']/i);
return m ? m[1] : null;
}
function extractAttr(attrs, name) {
const re = new RegExp(`\\b${name}=["']([^"']*)["']`, "i");
const m = attrs.match(re);
return m ? m[1] : null;
}
function extractStyleValue(attrs, prop) {
const m = attrs.match(new RegExp(`\\bstyle=["'][^"']*\\b${prop}:\\s*(\\d+)`, "i"));
return m ? m[1] : null;
}
async function writeReport(file, manifest, logs, assetMap) {
const rows = manifest.staticPages.map((page) => `<tr><td>${escapeHtml(page.path)}</td><td>${escapeHtml(page.title)}</td><td>${escapeHtml(page.timestamp)}</td><td>${escapeHtml(page.sourceUrl)}</td></tr>`).join("");
const validation = manifest.validation || { binRenamed: 0, brokenLinks: [], totalAssetsChecked: 0 };
const brokenRows = validation.brokenLinks.length
? validation.brokenLinks.map((b) => `<tr><td>${escapeHtml(b.url)}</td><td>${escapeHtml(b.source)}</td><td>${escapeHtml(String(b.status))}</td><td>${escapeHtml(b.reason)}</td></tr>`).join("")
: '<tr><td colspan="4" style="color:#058d28">Битых ссылок не найдено ✓</td></tr>';
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Archive Build Report</title><style>body{font-family:Arial,sans-serif;max-width:1100px;margin:40px auto;padding:0 20px;line-height:1.5}table{width:100%;border-collapse:collapse;margin:12px 0}td,th{border:1px solid #ddd;padding:8px;text-align:left}code,pre{background:#f4f4f4;padding:2px 4px}pre{white-space:pre-wrap;padding:14px}.summary{background:#f0f7ff;padding:14px;border-radius:6px;margin:16px 0}.broken{color:#c00}</style></head><body><h1>Archive Build Report</h1><div class="summary"><b>Static pages:</b> ${manifest.staticPages.length} | <b>Assets:</b> ${assetMap.size} | <b>Casino pages:</b> ${manifest.casino.pages.length}<br><b>Validation:</b> .bin→.html переименовано: ${validation.binRenamed} | Проверено ссылок: ${validation.totalAssetsChecked} | <span class="${validation.brokenLinks.length ? 'broken' : ''}">Битых: ${validation.brokenLinks.length}</span></div><h2>Pages</h2><table><thead><tr><th>Path</th><th>Title</th><th>Snapshot</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table><h2>Broken Links</h2><table><thead><tr><th>URL</th><th>Source</th><th>Status</th><th>Reason</th></tr></thead><tbody>${brokenRows}</tbody></table><h2>Design Tokens</h2><pre>${escapeHtml(JSON.stringify(manifest.design, null, 2))}</pre><h2>Design Assets</h2><pre>${escapeHtml(JSON.stringify(manifest.designAssets || {}, null, 2))}</pre><h2>Log</h2><pre>${escapeHtml(logs.join("\n"))}</pre></body></html>`;
await fs.writeFile(file, html, "utf8");
}
/**
* Валидация собранных данных:
* 1. Находит .bin файлы, которые на самом деле HTML, и переименовывает в .html.
* Попутно исправляет ссылки в архивном HTML.
* 2. Проверяет все ассеты на битые ссылки (404/недоступные).
*/
async function validateBuild(assetDir, staticPages, progressLogs) {
const result = { binRenamed: 0, brokenLinks: [], totalAssetsChecked: 0 };
// --- 1. .bin → .html ---
try {
const assetFiles = await listFilesRecursive(assetDir);
const renames = []; // [{oldFile, newFile, oldName, newName}]
for (const file of assetFiles) {
if (!file.endsWith(".bin")) continue;
const data = await fs.readFile(file);
const sample = data.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
if (sample.startsWith("<!doctype html") || sample.startsWith("<html")) {
// это HTML — переименуем в .html
const newName = path.basename(file, ".bin") + ".html";
const newFile = path.join(path.dirname(file), newName);
await fs.rename(file, newFile);
renames.push({
oldName: path.basename(file),
newName,
oldFile: file,
newFile,
});
result.binRenamed++;
}
}
// исправить ссылки в архивном HTML: .bin → .html
if (renames.length > 0) {
for (const sp of staticPages) {
const htmlFile = path.join(path.dirname(assetDir), "static-html", sp.file);
try {
let html = await fs.readFile(htmlFile, "utf8");
let changed = false;
for (const r of renames) {
if (html.includes(r.oldName)) {
html = html.split(r.oldName).join(r.newName);
changed = true;
}
}
if (changed) {
await fs.writeFile(htmlFile, html, "utf8");
}
} catch { /* файл мог быть удалён */ }
}
// также обновить assetMap — но он не доступен здесь; обновим через manifest позже
}
} catch (e) {
progressLogs.push(`Validation .bin error: ${e.message}`);
}
// --- 2. Проверка битых ссылок (асинхронно, с ограничением параллельности) ---
// Собираем все URL из архивного HTML и CSS, проверяем HEAD-запросом.
// Внимание: проверяем только внешние/архивные ссылки, не локальные ассеты.
try {
const checked = new Set();
const toCheck = [];
for (const sp of staticPages) {
const htmlFile = path.join(path.dirname(assetDir), "static-html", sp.file);
let html = "";
try { html = await fs.readFile(htmlFile, "utf8"); } catch { continue; }
// найти все href/src, ведущие на http/https
const matches = [...html.matchAll(/\b(?:href|src)=["']([^"']+)["']/gi)];
for (const m of matches) {
const url = m[1].split("#")[0].split("?")[0];
if (!url.startsWith("http")) continue;
if (checked.has(url)) continue;
checked.add(url);
// не проверяем wayback ссылки — они всегда отвечают
if (url.includes("web.archive.org")) continue;
toCheck.push({ url, source: sp.path });
}
}
result.totalAssetsChecked = toCheck.length;
progressLogs.push(`Validation: проверяю ${toCheck.length} внешних ссылок...`);
// проверяем пакетами по 5
const BATCH = 5;
for (let i = 0; i < toCheck.length; i += BATCH) {
const batch = toCheck.slice(i, i + BATCH);
const results = await Promise.allSettled(
batch.map(async (item) => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const r = await fetch(item.url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timer);
return { ...item, status: r.status, ok: r.ok };
} catch (e) {
return { ...item, status: 0, ok: false, reason: e.name === "AbortError" ? "timeout" : "unreachable" };
}
})
);
for (const r of results) {
if (r.status === "fulfilled") {
const val = r.value;
if (!val.ok && val.status !== 405 && val.status !== 403) {
// 405/403 часто отдаются для HEAD, хотя GET работает — не считаем битыми
result.brokenLinks.push({
url: val.url,
source: val.source,
status: val.status || val.reason || "unknown",
reason: val.status === 404 ? "404 Not Found"
: val.status === 0 ? "недоступен/timeout"
: `HTTP ${val.status}`,
});
}
}
}
}
} catch (e) {
progressLogs.push(`Validation links error: ${e.message}`);
}
return result;
}
function extractDesign(css) {
const colors = {};
for (const match of css.matchAll(/#[0-9a-f]{3,8}\b/gi)) {
const color = normalizeHex(match[0]);
colors[color] = (colors[color] || 0) + 1;
}
const ranked = Object.entries(colors).sort((a, b) => b[1] - a[1]).map(([color]) => color);
const fontMatch = css.match(/font-family\s*:\s*([^;{}]+)/i);
return {
primary: ranked[0] || "#224f9f",
accent: ranked[1] || ranked[0] || "#d14b2f",
background: "#ffffff",
text: "#1b1f24",
font: fontMatch ? fontMatch[1].replace(/["']/g, "").trim() : "Arial, sans-serif"
};
}
/**
* Извлечь designAssets из assetMap: hash'и для master.css, reset.css,
* фоновой картинки body, логотипа. Нужны чтобы стилизовать Casino-страницы
* под оригинальный дизайн на любом сайте.
*/
function extractDesignAssets(assetMap, cssTexts) {
const result = { cssMaster: "", cssReset: "", bgImage: "", logo: "", hero: "", sidebarHeader: "", bgBody: "", bgTop: "", bgInsertPage: "", bgFooter: "" };
// найти master.css и reset.css в assetMap по паттернам имени
for (const [url, info] of assetMap) {
const lower = url.toLowerCase();
if (/master\.css/i.test(lower) && !result.cssMaster) result.cssMaster = info.file;
if (/reset\.css/i.test(lower) && !result.cssReset) result.cssReset = info.file;
if (/logo/i.test(lower) && /\.(png|jpg|gif|svg)/i.test(info.file) && !result.logo) result.logo = info.file;
if (/(?:slidery|slider|slide)/i.test(lower) && /\.(png|jpe?g|webp)/i.test(info.file) && !result.hero) result.hero = info.file;
if (/(?:\/|_)restaurace1?\.(?:png|jpe?g|webp)/i.test(lower) && !result.sidebarHeader) result.sidebarHeader = info.file;
if (/bg_page|bg-body/i.test(lower) && !result.bgImage) result.bgImage = info.file;
if (/bg_header/i.test(lower) && !result.bgTop) result.bgTop = info.file;
if (/bg_insert_page/i.test(lower) && !result.bgInsertPage) result.bgInsertPage = info.file;
if (/bg_paticka|bg_footer/i.test(lower) && !result.bgFooter) result.bgFooter = info.file;
}
// из CSS найти фоновую картинку body (url(...) в правиле body{...})
const allCss = cssTexts.join("\n");
const bodyMatch = allCss.match(/body\s*\{[^}]*background\s*:\s*url\(([^)]+)\)/i);
if (bodyMatch && bodyMatch[1]) {
// найти этот файл в assetMap
const bgOrig = bodyMatch[1].replace(/^["']|["']$/g, "");
const bgBase = bgOrig.split("/").pop();
for (const [url, info] of assetMap) {
if (url.endsWith(bgBase) || url.includes(bgBase)) { result.bgImage = info.file; break; }
}
}
return result;
}
function shouldDownloadAsset(raw, attr) {
if (!raw || raw.startsWith("#") || /^(data:|mailto:|tel:|javascript:)/i.test(raw)) return false;
if (attr === "href" && !/\.(css|ico|png|jpe?g|gif|webp|svg|woff2?|ttf|otf)(\?|#|$)/i.test(raw)) return false;
return true;
}
function toAbsoluteAssetUrl(raw, base) {
try {
if (/^\/web\/\d+/i.test(raw)) {
const parsed = parseWaybackUrl(`https://web.archive.org${raw}`);
if (parsed) return parsed.original;
}
if (/web\.archive\.org\/web\/\d+/i.test(raw)) {
const parsed = parseWaybackUrl(raw);
if (parsed) return parsed.original;
}
return new URL(raw, base).href;
} catch {
return null;
}
}
async function fetchText(url) {
const response = await fetchWithTimeout(url, HTML_TIMEOUT_MS);
if (!response.ok) throw new Error(`Fetch failed ${response.status}: ${url}`);
return response.text();
}
async function fetchJson(url) {
const response = await fetchWithTimeout(url, HTML_TIMEOUT_MS);
if (!response.ok) throw new Error(`Fetch failed ${response.status}: ${url}`);
return response.json();
}
async function fetchWithTimeout(url, timeoutMs, retries = 3) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { redirect: "follow", signal: controller.signal });
clearTimeout(timer);
if (response.ok || response.status === 404) return response;
// 5xx — повторить
if (response.status >= 500 && attempt < retries) {
await new Promise(r => setTimeout(r, 2000 * attempt));
continue;
}
return response;
} catch (e) {
clearTimeout(timer);
lastError = e;
if (attempt < retries) {
const wait = 3000 * attempt;
console.error(`[retry ${attempt}/${retries}] ${e.message} — ждём ${wait}мс...`);
await new Promise(r => setTimeout(r, wait));
continue;
}
}
}
throw lastError || new Error("fetch failed after retries");
}
async function zipDirectory(sourceDir, destination, options = {}) {
await fs.rm(destination, { force: true });
const python = [
"import os,sys,zipfile",
"source=os.path.abspath(sys.argv[1])",
"destination=os.path.abspath(sys.argv[2])",
"include_root=sys.argv[3]=='1'",
"prefix=os.path.basename(source) if include_root else ''",
"with zipfile.ZipFile(destination,'w',zipfile.ZIP_DEFLATED) as archive:",
" for root,dirs,files in os.walk(source):",
" dirs.sort(); files.sort()",
" for name in files:",
" full=os.path.join(root,name)",
" rel=os.path.relpath(full,source).replace(os.sep,'/')",
" arcname=(prefix+'/'+rel) if prefix else rel",
" archive.write(full,arcname)"
].join("\n");
await new Promise((resolve, reject) => {
const child = spawn("python", ["-c", python, sourceDir, destination, options.includeRoot ? "1" : "0"], { stdio: "pipe" });
let stderr = "";
child.stderr.on("data", (data) => { stderr += data.toString(); });
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(stderr || `ZIP creation exited ${code}`)));
});
}
async function sendFile(res, file, attachment = false) {
try {
const data = await fs.readFile(file);
const mime = mimeForAssetFile(file, data);
res.writeHead(200, {
"Content-Type": mime,
"Content-Disposition": attachment ? `attachment; filename="${path.basename(file)}"` : `inline; filename="${path.basename(file)}"`
});
res.end(data);
} catch {
notFound(res);
}
}
function readJson(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", (chunk) => { data += chunk; });
req.on("end", () => {
try { resolve(JSON.parse(data || "{}")); } catch (error) { reject(error); }
});
req.on("error", reject);
});
}
function sendJson(res, data, status = 200) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data, null, 2));
}
function notFound(res) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found");
}
function normalizeOriginalUrl(value) {
const url = String(value).trim();
if (!/^https?:\/\//i.test(url)) return `https://${url}`;
return url;
}
function cleanDomain(value) {
return String(value || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "").trim();
}
function rewriteInternalSiteUrls(html, sourceDomain, targetDomain) {
const sourceHost = cleanDomain(sourceDomain).replace(/^www\./i, "");
if (!sourceHost) return html;
const targetBase = targetDomain ? `https://${cleanDomain(targetDomain)}` : "";
const sourcePattern = new RegExp(`https?:\\/\\/(?:www\\.)?${escapeRegex(sourceHost)}`, "gi");
return String(html).replace(sourcePattern, targetBase);
}
function normalizeRequestPath(pathname) {
const trimmed = "/" + String(pathname || "/").replace(/^\/+|\/+$/g, "");
return trimmed === "/" ? "/" : `${trimmed}/`;
}
function pathNameToFileStem(requestPath) {
return requestPath === "/" ? "home" : slugify(requestPath.replace(/^\/|\/$/g, "").replaceAll("/", "-"));
}
function titleFromPath(requestPath) {
if (requestPath === "/") return "Home";
return requestPath.replace(/^\/|\/$/g, "").split("/").pop().replaceAll("-", " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function extractTitle(html) {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return match ? decodeEntities(match[1].replace(/\s+/g, " ").trim()) : "";
}
function decodeEntities(value) {
// Базовые сущности
value = value.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#039;/g, "'");
// Чешские/латинские/европейские HTML-сущности (полный набор)
const entities = {
aacute:"á", Aacute:"Á", acirc:"â", Acirc:"Â", acute:"´", aelig:"æ", Agrave:"À", agrave:"à",
alefsym:"ℵ", alpha:"α", Alpha:"Α", amp:"&", and:"∧", ang:"∠", apos:"'", aring:"å", Aring:"Å",
asymp:"≈", atilde:"ã", Atilde:"Ã", auml:"ä", Auml:"Ä", bdquo:"„", beta:"β", Beta:"Β",
brvbar:"¦", bull:"•", cap:"∩", ccedil:"ç", Ccedil:"Ç", cedil:"¸", cent:"¢", chi:"χ", Chi:"Χ",
circ:"ˆ", clubs:"♣", cong:"≅", copy:"©", crarr:"↵", cup:"∪", curren:"¤", dArr:"⇓", dagger:"†",
Dagger:"‡", darr:"↓", deg:"°", delta:"δ", Delta:"Δ", diams:"♦", divide:"÷", eacute:"é",
Eacute:"É", ecirc:"ê", Ecirc:"Ê", egrave:"è", Egrave:"È", empty:"∅", emsp:" ", ensp:" ",
epsilon:"ε", Epsilon:"Ε", equiv:"≡", eta:"η", Eta:"Η", eth:"ð", ETH:"Ð", euml:"ë", Euml:"Ë",
euro:"€", exist:"∃", fnof:"ƒ", forall:"∀", frac12:"½", frac14:"¼", frac34:"¾", frasl:"⁄",
gamma:"γ", Gamma:"Γ", ge:"≥", gt:">", hArr:"⇔", harr:"↔", hearts:"♥", hellip:"…",
iacute:"í", Iacute:"Í", icirc:"î", Icirc:"Î", iexcl:"¡", igrave:"ì", Igrave:"Ì", image:"ℑ",
infin:"∞", int:"∫", iota:"ι", Iota:"Ι", iquest:"¿", isin:"∈", iuml:"ï", Iuml:"Ï",
kappa:"κ", Kappa:"Κ", lambda:"λ", Lambda:"Λ", lang:"⟨", laquo:"«", larr:"←",
lArr:"⇐", lceil:"⌈", ldquo:"“", le:"≤", lowast:"∗", loz:"◊", lrm:"", ltri:"◃",
macr:"¯", mdash:"—", micro:"µ", middot:"·", minus:"−", mu:"μ", Mu:"Μ", nabla:"∇",
nbsp:" ", ndash:"–", ne:"≠", ni:"∋", not:"¬", notin:"∉", nsub:"⊄", ntilde:"ñ", Ntilde:"Ñ",
nu:"ν", Nu:"Ν", oacute:"ó", Oacute:"Ó", ocirc:"ô", Ocirc:"Ô", oelig:"œ", OElig:"Œ",
ograve:"ò", Ograve:"Ò", oline:"‾", omega:"ω", Omega:"Ω", omicron:"ο", Omicron:"Ο",
opus:"⊕", or:"∨", ordf:"ª", ordm:"º", oslash:"ø", Oslash:"Ø", otilde:"õ", Otilde:"Õ",
otimes:"⊗", ouml:"ö", Ouml:"Ö", para:"¶", part:"∂", permil:"‰", perp:"⊥", phi:"φ",
Phi:"Φ", pi:"π", Pi:"Π", piv:"ϖ", plusmn:"±", pound:"£", prime:"′", Prime:"″",
prod:"∏", prop:"∝", psi:"ψ", Psi:"Ψ", quot:"\"", radic:"√", rang:"⟩", raquo:"»",
rarr:"→", rArr:"⇒", rceil:"⌉", rdquo:"”", real:"ℜ", reg:"®", rfloor:"⌋", rho:"ρ", Rho:"Ρ",
rlm:"", rsaquo:"›", rsquo:"’", rtri:"▹", sbquo:"‚", scaron:"š", Scaron:"Š", sect:"§",
shy:"", sigma:"σ", Sigma:"Σ", sigmaf:"ς", sim:"∼", spades:"♠", sub:"⊂", sube:"⊆", sum:"∑",
sup:"⊃", sup1:"¹", sup2:"²", sup3:"³", supe:"⊇", szlig:"ß", tau:"τ", Tau:"Τ",
there4:"∴", theta:"θ", Theta:"Θ", thetasym:"ϑ", thinsp:" ", thorn:"þ", THORN:"Þ",
tilde:"˜", times:"×", trade:"™", uArr:"⇑", uacute:"ú", Uacute:"Ú", uarr:"↑", ucirc:"û",
Ucirc:"Û", ugrave:"ù", Ugrave:"Ù", uml:"¨", upsih:"ϒ", upsilon:"υ", Upsilon:"Υ",
utilde:"ũ", utilde:"ũ", uuml:"ü", Uuml:"Ü", weierp:"℘", xi:"ξ", Xi:"Ξ", yacute:"ý",
Yacute:"Ý", yen:"¥", yuml:"ÿ", Yuml:"Ÿ", zeta:"ζ", Zeta:"Ζ", zwj:"", zwnj:""
};
// заменяем именованные сущности &name;
value = value.replace(/&([a-zA-Z]+);/g, (m, name) => entities[name] !== undefined ? entities[name] : m);
// заменяем числовые сущности &#NNN; и &#xNN;
value = value.replace(/&#(\d+);/g, (m, code) => { try { return String.fromCodePoint(parseInt(code, 10)); } catch { return m; } });
value = value.replace(/&#x([0-9a-fA-F]+);/g, (m, code) => { try { return String.fromCodePoint(parseInt(code, 16)); } catch { return m; } });
return value;
}
function slugify(value) {
return String(value || "item")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-") || "item";
}
function hash(value) {
return crypto.createHash("sha1").update(value).digest("hex").slice(0, 14);
}
function guessExtension(pathname) {
const ext = path.extname(pathname).toLowerCase();
if (ext && ext.length <= 6) return ext;
// Нет расширения (PHP endpoints, iframe-источники) — предположим HTML.
// Раньше возвращали .bin, что заставляло браузер скачивать.
return ".html";
}
function normalizeHex(color) {
if (color.length === 4) return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
if (color.length === 9) return color.slice(0, 7);
return color.toLowerCase();
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#039;" }[char]));
}
function escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function validateFullStaticDocument(html, label = "static page") {
const value = String(html || "");
const missing = ["html", "head", "body"].filter((tag) => !new RegExp(`<${tag}\\b`, "i").test(value));
if (missing.length) throw new Error(`${label} is not a full HTML document (missing ${missing.join(", ")}).`);
return true;
}
function validateNoUnresolvedAssetReferences(html, label = "static page") {
const unresolved = new Set();
for (const match of String(html || "").matchAll(/\b(?:src|href)=(['"])([^'"]+)\1/gi)) {
const raw = match[2].trim();
if (!raw || /^(?:data:|#|mailto:|tel:|javascript:|\{\{ACI_ASSET_URL\}\})/i.test(raw)) continue;
const pathname = raw.split(/[?#]/, 1)[0];
if (/\.(?:css|js|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|pdf|mp4|webm)$/i.test(pathname)) unresolved.add(raw);
}
if (unresolved.size) {
throw new Error(`${label} still contains remote or unresolved asset URLs: ${[...unresolved].slice(0, 10).join(", ")}`);
}
return true;
}
async function validateStaticAssetReferences(html, assetDir, label = "static page") {
const names = new Set();
for (const match of String(html || "").matchAll(/\{\{ACI_ASSET_URL\}\}\/([^\s"'?#<>]+)/g)) {
names.add(path.basename(match[1]));
}
const missing = [];
for (const name of names) {
try {
await fs.access(path.join(assetDir, name));
} catch {
missing.push(name);
}
}
if (missing.length) throw new Error(`${label} references missing local assets: ${missing.join(", ")}`);
return names.size;
}
export {
applySafeTextReplacements,
discoverArchiveUrls,
ensureEditableContentMarkers,
ensureEditableContentWrapper,
ensureEditableFooterMarkers,
extractEditableFooterContent,
normalizeConfig,
parseCasinoPages,
parseMenuItems,
parseTextReplacements,
replaceEditableContentRegion,
replaceEditableFooterRegion,
sanitizeAiProposal,
validateEditableHtmlFragment,
validateFullStaticDocument,
validateNoUnresolvedAssetReferences,
validateStaticAssetReferences,
writeTheme,
zipDirectory
};