coingimp's picture
Fix: prevent page duplication (106→6), remove init hook
25fb6ba verified
Raw
History Blame Contribute Delete
111 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";
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 HTML_TIMEOUT_MS = 20000;
const ASSET_TIMEOUT_MS = 12000;
const MAX_ASSETS = 250;
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
};
const previewSessions = new Map();
const approvalWaiters = new Map();
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
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") {
const body = await readJson(req);
const result = await buildProject(body);
return sendJson(res, result);
}
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);
const manifest = await getBuildManifest(buildId);
const pages = (manifest?.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 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");
// подставить плейсхолдеры для корректного отображения в редакторе
html = html.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`);
html = html.replaceAll("{{ACI_SITE_URL}}", "/");
return sendJson(res, { ok: true, file, content: html });
} 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);
const filePath = path.join(session.staticDir, path.basename(file));
if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400);
// вернуть плейсхолдеры обратно перед сохранением
let html = content;
html = html.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}");
html = html.replaceAll("/", "{{ACI_SITE_URL}}").replaceAll("{{ACI_SITE_URL}}/", "/"); // аккуратно
await fs.writeFile(filePath, html, "utf8");
// обновить gutenbergContent в manifest
await updateBuildManifest(buildId, (manifest) => {
const page = manifest.staticPages.find((p) => p.file === file);
if (page) {
const parts = splitPageHtml(html, manifest.contentSelector);
page.gutenbergContent = htmlToGutenberg(parts.contentHtml);
}
});
return sendJson(res, { ok: true });
}
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, stack: error.stack }, 500);
}
});
server.listen(port, () => {
console.log(`Archive Casino WP Builder is running at http://localhost:${port}/`);
});
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 logs = [];
progressState = {
active: true,
buildId,
updatedAt: new Date().toISOString(),
logs: [],
summary: { pages: 0, assets: 0, casinoPages: config.casinoPages.length },
awaitingApproval: false,
preview: null
};
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(themeDir, { recursive: true });
const assetMap = new Map();
const cssTexts = [];
const staticPages = [];
previewSessions.set(buildId, { buildDir, staticDir, assetDir });
let shellHtmlGlobal = ""; // шапка главной страницы — пойдёт в header.php темы
let footerHtmlGlobal = ""; // футер — в footer.php
for (const sourceUrl of config.urls) {
progressLogs.push(`Resolving ${sourceUrl}`);
const snapshot = await resolveSnapshot(sourceUrl, config);
progressLogs.push(`Using snapshot ${snapshot.timestamp} for ${snapshot.original}`);
const htmlUrl = `https://web.archive.org/web/${snapshot.timestamp}id_/${snapshot.original}`;
const rawHtml = await fetchText(htmlUrl);
let html = cleanWaybackHtml(rawHtml);
html = await rewriteHtmlAssets(html, snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs);
html = html
.replaceAll(snapshot.original, config.targetDomain ? `https://${config.targetDomain}` : snapshot.original)
.replaceAll("{{ACI_SITE_URL}}", "");
// === Фаза очистки ссылок (авто-удаление 404 + внешних) ===
const cleaned = await cleanExternalLinks(html, config.sourceDomain, progressLogs);
html = cleaned.cleanedHtml;
// === Разборка на части (shell / content / footer) ===
const parts = splitPageHtml(html, config.contentSelector);
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}`);
}
// Сохранить shell/footer для темы
config._shellHtml = shellHtmlGlobal;
config._footerHtml = footerHtmlGlobal;
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
};
const previewPages = staticPages.map((page) => ({
title: page.title,
path: page.path,
url: `/preview/${buildId}/pages/${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
};
return {
ok: true,
buildId,
files: [
{ name: themeZipName, url: `/download/${themeZipName}` },
{ name: reportName, url: `/download/${reportName}` }
],
summary: {
pages: staticPages.length,
assets: assetMap.size,
casinoPages: config.casinoPages.length
},
logs
};
}
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") {
const file = path.join(session.staticDir, name);
if (!file.startsWith(session.staticDir)) return notFound(res);
try {
const html = await fs.readFile(file, "utf8");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html
.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`)
.replaceAll("{{ACI_SITE_URL}}", "/"));
} catch {
notFound(res);
}
return;
}
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);
}
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) {
return String(value || DEFAULT_STATIC_MENU)
.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, "/") };
});
}
const DEFAULT_CASINO_PAGES = "/casino/ | Casino\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) {
if (index === 0) return "/casino/";
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 urls = String(input.urls || "")
.split(/\r?\n|,/)
.map((value) => value.trim())
.filter(Boolean);
if (!urls.length) throw new Error("Добавьте хотя бы один URL.");
const casinoPages = parseCasinoPages(input.casinoPages, input.nestedCasino !== false);
const menuItems = parseMenuItems(input.staticMenuItems);
return {
sourceDomain: cleanDomain(input.sourceDomain),
targetDomain: cleanDomain(input.targetDomain),
snapshotMode: input.snapshotMode || "latest",
snapshotDate: input.snapshotDate || "",
snapshotTimestamp: input.snapshotTimestamp || "",
themeName: input.themeName || "Recovered Casino Theme",
casinoMenuLabel: input.casinoMenuLabel || "Casino",
casinoRootPath: "/casino/",
menuItems,
nestedCasino: input.nestedCasino !== false,
urls,
casinoPages
};
}
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 to = config.snapshotMode === "date" && config.snapshotDate
? `&to=${config.snapshotDate.replaceAll("-", "")}235959`
: "";
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 });
}
for (const item of replacements) {
try {
const local = await downloadAsset(item.absolute, timestamp, assetDir, assetMap, cssTexts, logs);
html = html.replaceAll(item.raw, `{{ACI_ASSET_URL}}/${local}`);
} catch (error) {
logs.push(`Asset skipped: ${item.absolute} (${error.message})`);
}
}
return html;
}
async function downloadAsset(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}`;
const response = await fetchWithTimeout(archivedUrl, ASSET_TIMEOUT_MS);
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 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}.aci-casino-toggle{width:100%;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:0}.aci-casino-toggle:after{content:" +";float:right}.aci-casino-accordion.is-open>.aci-casino-toggle:after{content:" -"}.aci-casino-submenu{display:none;margin:0;padding-left:16px}.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(/<\/head>/i, css + '</head>');
if (!html.includes('aci-casino-menu-script')) html = html.replace(/<\/body>/i, js + '</body>');
return html;
}
function pruneStaticMenus(html, config) {
const menu = fixedProjectMenuHtml(config);
html = html.replace(/(<div class=["']menu["']>\s*<ul[^>]*>)[\s\S]*?(<\/ul>)/i, '$1' + menu + '$2');
return injectStaticCasinoAccordion(html);
}
function normalizeStaticHtmlForTheme(html, config) {
html = html.replace(/<nav class=["']aci-recovered-nav["']>[\s\S]*?<\/nav>\s*/gi, "");
html = pruneStaticMenus(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 embedded = await buildEmbeddedThemeData(themeDir, manifest, config);
const style = `/*
Theme Name: ${config.themeName}
Author: Archive Casino WP Builder
Version: 1.0.6
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: 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; }
/* === 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; }
`;
// Демо-контент Casino (на русском, в стиле сайта) — вставляется в PHP-шаблон.
// Экранируем обратные слеши и $ чтобы PHP получил литеральную строку.
const casinoContentPhp = casinoDemoContent("__TITLE__").replace(/\$/g, "\\\\$").replace(/__TITLE__/g, "' . $title . '");
const functions = `<?php
if (!defined('ABSPATH')) { exit; }
function acwpb_embedded_decode($chunks) {
return base64_decode(implode('', $chunks));
}
function acwpb_theme_embedded_manifest() {
$json = acwpb_embedded_decode(${embedded.manifestChunks});
$manifest = json_decode($json, true);
if (!is_array($manifest)) {
$manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]];
}
return $manifest;
}
function acwpb_theme_embedded_static_html($file) {
switch (basename((string) $file)) {
${embedded.staticCases}
default: return '';
}
}
function acwpb_theme_embedded_gutenberg($file) {
switch (basename((string) $file)) {
${embedded.gutenbergCases}
default: return '';
}
}
function acwpb_theme_embedded_asset_payload($file) {
switch (basename((string) $file)) {
${embedded.assetCases}
default: return null;
}
}
function acwpb_theme_manifest() {
static $manifest = null;
if ($manifest !== null) { return $manifest; }
$fallback = acwpb_theme_embedded_manifest();
$file = get_template_directory() . '/data/pages.json';
if (!file_exists($file)) {
$manifest = $fallback;
return $manifest;
}
$json = file_get_contents($file);
$manifest = json_decode($json, true);
if (!is_array($manifest)) { $manifest = $fallback; }
if (!isset($manifest['staticPages']) || !is_array($manifest['staticPages'])) { $manifest['staticPages'] = []; }
if (!isset($manifest['casino']) || !is_array($manifest['casino'])) { $manifest['casino'] = $fallback['casino']; }
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 acwpb_theme_setup() {
add_theme_support('title-tag');
add_theme_support('post-thumbnails');
add_theme_support('wp-block-styles');
add_theme_support('editor-styles');
register_nav_menus(['primary' => 'Primary Menu']);
add_editor_style('style.css');
}
add_action('after_setup_theme', 'acwpb_theme_setup');
function acwpb_theme_assets() {
wp_enqueue_style('acwpb-theme-style', get_stylesheet_uri(), [], '1.0.6');
// На Casino-страницах подключаем архивные CSS + фоновую картинку,
// чтобы они были стилизованы под основной сайт.
if (is_page() && !is_front_page()) {
$post_id = get_the_ID();
$is_casino = get_post_meta($post_id, '_acwpb_casino_page', true);
if (!$is_casino) {
// проверим родителя
$parent_id = wp_get_post_parent_id($post_id);
if ($parent_id) {
$parent_slug = get_page_uri($parent_id);
if (strpos($parent_slug, 'casino') !== false) { $is_casino = true; }
}
}
if ($is_casino) {
$manifest_assets = acwpb_theme_manifest();
$da = $manifest_assets['designAssets'] ?? [];
$asset_base = esc_url(home_url('/acwpb-assets'));
$css_master = $da['cssMaster'] ?? '';
$css_reset = $da['cssReset'] ?? '';
$bg = $da['bgImage'] ?? '';
$logo = $da['logo'] ?? '';
if ($css_reset) { wp_enqueue_style('acwpb-reset', $asset_base . '/' . $css_reset, [], '1.0'); }
if ($css_master) { wp_enqueue_style('acwpb-master', $asset_base . '/' . $css_master, ['acwpb-reset'], '1.0'); }
// inline-override: фоновая картинка body
$override = '';
if ($bg) {
$override .= 'body{background:url(' . $asset_base . '/' . $bg . ') repeat-x fixed top center !important;}';
}
if ($logo) {
$override .= '.site-brand img, .logo img{content:url(' . $asset_base . '/' . $logo . ');}';
}
if ($override && $css_master) {
wp_add_inline_style('acwpb-master', $override);
} elseif ($override) {
wp_add_inline_style('acwpb-theme-style', $override);
}
}
}
}
add_action('wp_enqueue_scripts', 'acwpb_theme_assets');
add_action('enqueue_block_editor_assets', 'acwpb_theme_assets');
function acwpb_theme_static_html($file) {
$path = get_template_directory() . '/data/static-html/' . basename($file);
if (file_exists($path)) {
$html = file_get_contents($path);
} else {
$html = acwpb_theme_embedded_static_html($file);
}
$html = str_replace('{{ACI_ASSET_URL}}', esc_url(home_url('/acwpb-assets')), $html);
$html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html);
return $html;
}
function acwpb_theme_static_router() {
if (is_admin()) { return; }
$raw_path = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '/';
$raw_path = '/' . ltrim(rawurldecode((string) $raw_path), '/');
if ($raw_path === '/') {
wp_redirect(home_url('/cz/'), 301);
exit;
}
if (preg_match('#^/acwpb-assets/([^/]+)$#', $raw_path, $match)) {
$payload = acwpb_theme_embedded_asset_payload($match[1]);
if (!$payload) { status_header(404); exit; }
status_header(200);
header('Content-Type: ' . $payload['mime']);
// Для HTML-ассетов НЕ добавляем filename — иначе браузер видит
// filename=".bin" и предлагает скачать вместо отображения.
if (strpos($payload['mime'], 'text/html') === false) {
header('Content-Disposition: inline; filename="' . basename($match[1]) . '"');
} else {
header('Content-Disposition: inline');
}
header('Cache-Control: public, max-age=31536000, immutable');
header('Content-Length: ' . strlen($payload['body']));
echo $payload['body'];
exit;
}
$manifest = acwpb_theme_manifest();
$path = trailingslashit($raw_path);
// Статичные страницы больше НЕ перехватываем template_redirect —
// они рендерятся через page.php темы с Gutenberg-блоками из post_content.
// Это позволяет редактировать их в WordPress без «слёта кода».
}
add_action('template_redirect', 'acwpb_theme_static_router', 1);
/**
* Полностью заменяет меню в архивном HTML на правильное из manifest
* (только нужные пункты) + Casino-аккордеон. Никаких дублей.
*/
function acwpb_theme_patch_static_menu($html) {
$manifest = acwpb_theme_manifest();
$items_html = '';
foreach (($manifest['menuItems'] ?? []) as $mi) {
$title = esc_html($mi['title'] ?? '');
$mpath = esc_url(home_url($mi['path'] ?? '#'));
$items_html .= '<li><a href="' . $mpath . '">' . $title . '</a></li>';
}
// Casino-аккордеон
$casino_label = esc_html($manifest['casino']['menuLabel'] ?? 'Casino');
$submenu = '';
foreach (($manifest['casino']['pages'] ?? []) as $cp) {
$ctitle = esc_html($cp['title'] ?? 'Casino');
$cpath = esc_url(home_url($cp['path'] ?? '#'));
$submenu .= '<li><a href="' . $cpath . '">' . $ctitle . '</a></li>';
}
$items_html .= '<li class="menu-item-has-children"><a href="#" onclick="return false;">' . $casino_label . ' ▾</a><ul class="sub-menu">' . $submenu . '</ul></li>';
// Полностью заменить содержимое <ul> внутри <div class="menu">
if (preg_match('#(<div[^>]*class="menu"[^>]*>\\s*<ul[^>]*>)[\\s\\S]*?(</ul>)#i', $html)) {
$html = preg_replace('#(<div[^>]*class="menu"[^>]*>\\s*<ul[^>]*>)[\\s\\S]*?(</ul>)#i',
'$1' . $items_html . '$2', $html, 1);
} elseif (preg_match('#(<ul[^>]*>)[\\s\\S]*?O restauraci[\\s\\S]*?(</ul>)#i', $html)) {
$html = preg_replace('#(<ul[^>]*>)[\\s\\S]*?O restauraci[\\s\\S]*?(</ul>)#i',
'$1' . $items_html . '$2', $html, 1);
}
// CSS для аккордеона в архивных страницах
if (strpos($html, 'aci-accordion-style') === false) {
$css = '<style id="aci-accordion-style">' .
'.menu ul, .menu li { list-style: none; }' .
'.menu-item-has-children { position: relative; }' .
'.menu-item-has-children > a { cursor: pointer; }' .
'.menu .sub-menu { display: none; position: absolute; left: 0; top: 100%; background: #fff; border: 1px solid #e7d2a0; border-radius: 6px; padding: 6px 0; margin: 2px 0 0; min-width: 200px; list-style: none; z-index: 1000; box-shadow: 0 6px 18px rgba(0,0,0,0.12); }' .
'.menu-item-has-children:hover > .sub-menu, .menu-item-has-children.is-open > .sub-menu { display: block; }' .
'.menu .sub-menu li { white-space: nowrap; }' .
'.menu .sub-menu a { display: block; padding: 8px 16px; }' .
'.menu .sub-menu a:hover { background: #f7eee6; }' .
'</style>';
$html = preg_replace('#</head>#i', $css . '</head>', $html, 1);
}
return $html;
}
function acwpb_theme_activate() {
$manifest = acwpb_theme_manifest();
// ЗАЩИТА ОТ ДУБЛИКАТОВ: если импорт уже выполнен — выходим.
// Это предотвращает создание 106 страниц вместо 6.
$import_hash = md5(json_encode($manifest));
if (get_option('acwpb_theme_imported') === $import_hash) {
return; // уже импортировано, не повторяем
}
$menu_id = acwpb_theme_menu_id();
acwpb_theme_clear_menu($menu_id);
$casino_root_id = 0;
$static_root_id = 0;
// Создаём родителя /casino/ (для URL-структуры /casino/<slug>/),
// в меню он не выводится — только как post_parent.
$casino_root_id = acwpb_theme_upsert_page('Casino', 'casino', '<!-- wp:paragraph --><p>Раздел Casino.</p><!-- /wp:paragraph -->');
if (is_wp_error($casino_root_id)) { $casino_root_id = 0; }
foreach ($manifest['staticPages'] as $page) {
if (empty($page['title']) || empty($page['path']) || empty($page['file'])) { continue; }
$slug = acwpb_theme_slug_from_path($page['path']);
// Контент страницы — КОНВЕРТИРОВАННЫЕ Gutenberg-блоки (не голый HTML!).
// Это позволяет редактировать страницу в WordPress-редакторе без «слёта кода».
$content = acwpb_theme_embedded_gutenberg($page['file']);
if (empty($content)) {
// fallback: если gutenberg-контент не найден, берём полный HTML
$raw_html = acwpb_theme_static_html($page['file']);
$raw_html = str_replace('{{ACI_ASSET_URL}}', esc_url(home_url('/acwpb-assets')), $raw_html);
$content = '<!-- wp:html -->' . $raw_html . '<!-- /wp:html -->';
} else {
// подставляем плейсхолдеры путей в блоки
$content = str_replace('{{ACI_ASSET_URL}}', esc_url(home_url('/acwpb-assets')), $content);
}
$post_id = acwpb_theme_upsert_page($page['title'], $slug, $content);
if (is_wp_error($post_id) || !$post_id) { continue; }
update_post_meta($post_id, '_acwpb_static_path', $page['path']);
update_post_meta($post_id, '_acwpb_static_file', $page['file']);
if ($page['path'] === '/' || $page['path'] === '/cz/') {
$static_root_id = $post_id;
}
}
foreach ($manifest['casino']['pages'] as $index => $page) {
if (empty($page['title']) || empty($page['slug'])) { continue; }
$post_id = acwpb_theme_upsert_page($page['title'], $page['slug'], acwpb_theme_casino_content($page['title']));
if (is_wp_error($post_id) || !$post_id) { continue; }
// выставляем parent, чтобы URL был /casino/<slug>/
if ($casino_root_id) {
wp_update_post(['ID' => $post_id, 'post_parent' => $casino_root_id]);
}
}
foreach (acwpb_theme_primary_menu_items() as $item) {
acwpb_theme_add_menu_url($menu_id, $item['title'], home_url($item['path']));
}
// Casino как аккордеон: родитель — custom-пункт (ссылка #, без страницы),
// все casino-страницы — дочерние (submenu).
$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'
]);
if (!empty($parent_db)) {
foreach ($casino_pages_menu as $cp) {
$child_post = get_page_by_path(sanitize_title($cp['slug'] ?? ''), OBJECT, 'page');
if ($child_post) {
wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $cp['title'] ?? 'Casino',
'menu-item-object' => 'page',
'menu-item-object-id' => $child_post->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);
}
update_option('acwpb_theme_imported', md5(json_encode($manifest)));
flush_rewrite_rules();
}
add_action('after_switch_theme', 'acwpb_theme_activate');
// ВНИМАНИЕ: хук 'init' → acwpb_theme_bootstrap_import УБРАН!
// Он вызывал создание дубликатов при каждом запросе (106 страниц вместо 6).
// Импорт работает ТОЛЬКО при активации темы (after_switch_theme)
// или вручную через кнопку на странице "Recovered Site Import".
function acwpb_theme_admin_auto_import() {
// Авто-импорт отключён.
}
// add_action('admin_init', 'acwpb_theme_admin_auto_import'); // отключено
// add_action('admin_init', 'acwpb_theme_admin_auto_import'); // отключено
function acwpb_theme_admin_menu() {
add_theme_page('Recovered Site Import', 'Recovered Site Import', 'manage_options', 'acwpb-theme-import', 'acwpb_theme_import_page');
}
add_action('admin_menu', 'acwpb_theme_admin_menu');
function acwpb_theme_import_page() {
if (!current_user_can('manage_options')) { return; }
$manifest = acwpb_theme_manifest();
$message = '';
if (isset($_POST['acwpb_theme_import']) && check_admin_referer('acwpb_theme_import')) {
acwpb_theme_activate();
$message = 'Import completed.';
}
$counts = acwpb_theme_import_counts($manifest);
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>Expected static pages: ' . intval(count($manifest['staticPages'])) . '. Created: ' . intval($counts['static']) . '.</p>';
echo '<p>Expected Casino pages: ' . intval(count($manifest['casino']['pages'])) . '. Created: ' . intval($counts['casino']) . '.</p>';
echo '<form method="post">';
wp_nonce_field('acwpb_theme_import');
submit_button('Run import now', 'primary', 'acwpb_theme_import');
echo '</form></div>';
}
function acwpb_theme_import_complete($manifest) {
$counts = acwpb_theme_import_counts($manifest);
return $counts['static'] >= count($manifest['staticPages']) && $counts['casino'] >= count($manifest['casino']['pages']);
}
function acwpb_theme_import_counts($manifest) {
$static_count = 0;
foreach ($manifest['staticPages'] as $page) {
if (empty($page['path'])) { continue; }
if (get_page_by_path(acwpb_theme_slug_from_path($page['path']))) { $static_count++; }
}
$casino_count = 0;
foreach ($manifest['casino']['pages'] as $page) {
if (empty($page['slug'])) { continue; }
if (get_page_by_path($page['slug'])) { $casino_count++; }
}
return ['static' => $static_count, 'casino' => $casino_count];
}
function acwpb_theme_slug_from_path($path) {
$slug = trim($path, '/');
if ($slug === '') { return 'home'; }
return str_replace('/', '-', $slug);
}
function acwpb_theme_primary_menu_items() {
// Динамически из manifest: пользователь задаёт пункты меню в UI,
// они попадают в pages.json -> manifest.menuItems.
$manifest = acwpb_theme_manifest();
$items = [];
if (!empty($manifest['menuItems']) && is_array($manifest['menuItems'])) {
foreach ($manifest['menuItems'] as $item) {
if (!empty($item['title']) && !empty($item['path'])) {
$items[] = ['title' => $item['title'], 'path' => $item['path']];
}
}
}
// fallback если manifest пуст
if (empty($items)) {
$items = [
['title' => 'O restauraci', 'path' => '/cz/1-O-restauraci/'],
['title' => 'Polední menu', 'path' => '/cz/9-Poledni-menu/'],
['title' => 'Fotogalerie', 'path' => '/cz/15-Fotogalerie/'],
['title' => 'Kontakty', 'path' => '/cz/17-Kontakty/'],
];
}
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)) { return; }
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 || acwpb_theme_menu_has_url($menu_id, $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_page($menu_id, $title, $post_id) {
if (!$menu_id || !$post_id) { return 0; }
// если уже есть — вернуть существующий ID
$existing = acwpb_theme_find_menu_item_by_object($menu_id, $post_id, 0);
if ($existing) { return $existing; }
$db_id = wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $title,
'menu-item-object' => 'page',
'menu-item-object-id' => $post_id,
'menu-item-type' => 'post_type',
'menu-item-status' => 'publish'
]);
return $db_id ? $db_id : 0;
}
function acwpb_theme_add_menu_page_child($menu_id, $title, $post_id, $parent_db_id) {
if (!$menu_id || !$post_id || !$parent_db_id) { return; }
if (acwpb_theme_find_menu_item_by_object($menu_id, $post_id, $parent_db_id)) { return; }
wp_update_nav_menu_item($menu_id, 0, [
'menu-item-title' => $title,
'menu-item-object' => 'page',
'menu-item-object-id' => $post_id,
'menu-item-type' => 'post_type',
'menu-item-parent-id' => $parent_db_id,
'menu-item-status' => 'publish'
]);
}
function acwpb_theme_find_menu_item_by_object($menu_id, $object_id, $parent_db_id = 0) {
$items = wp_get_nav_menu_items($menu_id);
if (!is_array($items)) { return 0; }
foreach ($items as $item) {
if (intval($item->object_id) === intval($object_id) && intval($item->menu_item_parent) === intval($parent_db_id)) {
return $item->db_id;
}
}
return 0;
}
function acwpb_theme_menu_has_url($menu_id, $url) {
$items = wp_get_nav_menu_items($menu_id);
if (!is_array($items)) { return false; }
foreach ($items as $item) {
if (!empty($item->url) && untrailingslashit($item->url) === untrailingslashit($url)) { return true; }
}
return false;
}
function acwpb_theme_menu_has_object($menu_id, $post_id) {
$items = wp_get_nav_menu_items($menu_id);
if (!is_array($items)) { return false; }
foreach ($items as $item) {
if (intval($item->object_id) === intval($post_id)) { return true; }
}
return false;
}
function acwpb_theme_upsert_page($title, $slug, $content) {
$slug = sanitize_title($slug);
// Сначала ищем по post_name (точный match), потом по path
$existing = get_page_by_path($slug, OBJECT, 'page');
if (!$existing) {
// fallback: прямой запрос по post_name
$existing = get_posts([
'post_type' => 'page',
'post_status' => 'any',
'name' => $slug,
'numberposts' => 1,
]);
$existing = is_array($existing) && count($existing) ? $existing[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']);
}
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', '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(); ?>
<header class="site-header">
<a class="site-brand" href="<?php echo esc_url(home_url('/')); ?>"><?php bloginfo('name'); ?></a>
<nav class="site-nav" aria-label="Primary navigation">
<?php wp_nav_menu(['theme_location' => 'primary', 'container' => false, 'fallback_cb' => false]); ?>
</nav>
</header>
<main class="site-main">
`;
const footer = `</main>
<footer class="site-footer">
<small>&copy; <?php echo esc_html(date('Y')); ?> <?php bloginfo('name'); ?></small>
</footer>
<?php wp_footer(); ?>
</body>
</html>
`;
const page = `<?php get_header(); ?>
<?php while (have_posts()) : the_post(); ?>
<article <?php post_class(); ?>>
<?php the_content(); ?>
</article>
<?php endwhile; ?>
<?php get_footer(); ?>
`;
await fs.writeFile(path.join(themeDir, "style.css"), style, "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");
}
// ---------------------------------------------------------------------------
// Генерация демо-контента 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 "?"; }
}
// ===========================================================================
// ФАЗА РАЗБОРКИ: 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: "", 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 (/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) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { redirect: "follow", signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function zipDirectory(sourceDir, destination, options = {}) {
await fs.rm(destination, { force: true });
const archivePath = options.includeRoot ? sourceDir : path.join(sourceDir, "*");
await new Promise((resolve, reject) => {
const child = spawn("powershell", [
"-NoProfile",
"-Command",
"Compress-Archive",
"-Path",
archivePath,
"-DestinationPath",
destination,
"-Force"
], { stdio: "pipe" });
let stderr = "";
child.stderr.on("data", (data) => { stderr += data.toString(); });
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(stderr || `Compress-Archive 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 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, "\\$&");
}