| import http from "node:http";
|
| import { promises as fs } from "node:fs";
|
| import path from "node:path";
|
| import { fileURLToPath } from "node:url";
|
| import crypto from "node:crypto"; |
| import { spawn } from "node:child_process"; |
| import sharp from "sharp"; |
|
|
| const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
| const outputsDir = path.join(__dirname, "outputs");
|
| const publicDir = path.join(__dirname, "public"); |
| const buildRoot = path.join(__dirname, "build"); |
| const port = Number(process.env.PORT || 8765); |
| const host = process.env.HOST || "0.0.0.0"; |
| const HTML_TIMEOUT_MS = 20000;
|
| const ASSET_TIMEOUT_MS = 12000;
|
| const MAX_ASSETS = 250; |
| const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions"; |
| const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-flash"; |
| const EDGE_PATH = process.env.EDGE_PATH || "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; |
| const APP_USERNAME = process.env.APP_USERNAME || "admin"; |
| const APP_PASSWORD = process.env.APP_PASSWORD || ""; |
| const AI_RATE_LIMIT_PER_HOUR = Math.max(1, Number(process.env.AI_RATE_LIMIT_PER_HOUR || 20)); |
|
|
| const mimeTypes = {
|
| ".html": "text/html; charset=utf-8",
|
| ".css": "text/css; charset=utf-8",
|
| ".js": "application/javascript; charset=utf-8",
|
| ".json": "application/json; charset=utf-8",
|
| ".ico": "image/x-icon",
|
| ".jpg": "image/jpeg",
|
| ".jpeg": "image/jpeg",
|
| ".png": "image/png",
|
| ".gif": "image/gif",
|
| ".webp": "image/webp",
|
| ".svg": "image/svg+xml",
|
| ".woff": "font/woff",
|
| ".woff2": "font/woff2",
|
| ".ttf": "font/ttf",
|
| ".zip": "application/zip"
|
| };
|
|
|
| let progressState = {
|
| active: false,
|
| buildId: null,
|
| updatedAt: null,
|
| logs: [],
|
| summary: null, |
| awaitingApproval: false, |
| preview: null, |
| files: [] |
| };
|
| const previewSessions = new Map(); |
| const approvalWaiters = new Map(); |
| const assetDownloadPromises = new Map(); |
| const aiRateLimits = new Map(); |
|
|
| const server = http.createServer(async (req, res) => { |
| try { |
| const url = new URL(req.url, `http://${req.headers.host}`); |
|
|
| if (!authorizeRequest(req, res)) return; |
|
|
| if (req.method === "GET" && url.pathname === "/api/status") {
|
| return sendJson(res, { ok: true, port, outputsDir });
|
| }
|
|
|
| if (req.method === "GET" && url.pathname === "/api/progress") {
|
| return sendJson(res, { ok: true, ...progressState });
|
| }
|
|
|
| if (req.method === "POST" && url.pathname === "/api/continue") {
|
| const body = await readJson(req);
|
| const waiter = approvalWaiters.get(body.buildId);
|
| if (!waiter) return sendJson(res, { ok: false, error: "No build is waiting for approval." }, 404);
|
| approvalWaiters.delete(body.buildId);
|
| progressState.awaitingApproval = false;
|
| progressState.preview = null;
|
| progressState.updatedAt = new Date().toISOString();
|
| waiter();
|
| return sendJson(res, { ok: true });
|
| }
|
|
|
| if (req.method === "POST" && url.pathname === "/api/build") { |
| if (progressState.active) { |
| return sendJson(res, { ok: false, error: "A build is already running or waiting for preview approval.", buildId: progressState.buildId }, 409); |
| } |
| const body = await readJson(req); |
| try { |
| const result = await buildProject(body); |
| return sendJson(res, result); |
| } catch (error) { |
| progressState.active = false; |
| progressState.awaitingApproval = false; |
| progressState.updatedAt = new Date().toISOString(); |
| progressState.error = error.message; |
| throw error; |
| } |
| } |
|
|
| if (req.method === "GET" && url.pathname.startsWith("/download/")) {
|
| const name = path.basename(decodeURIComponent(url.pathname.replace("/download/", "")));
|
| const file = path.join(outputsDir, name);
|
| if (!file.startsWith(outputsDir)) return notFound(res);
|
| return sendFile(res, file, true);
|
| }
|
|
|
| if (req.method === "GET" && url.pathname.startsWith("/preview/")) {
|
| return sendPreviewFile(res, url.pathname);
|
| }
|
|
|
|
|
|
|
|
|
| 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 staticPages = session.staticPages || [];
|
| const pages = staticPages.map((p) => ({
|
| title: p.title,
|
| path: p.path,
|
| file: p.file,
|
| url: `/preview/${buildId}/pages/${encodeURIComponent(p.file)}`
|
| }));
|
| return sendJson(res, { ok: true, buildId, pages });
|
| }
|
|
|
|
|
| if (req.method === "GET" && url.pathname === "/api/page") {
|
| const buildId = url.searchParams.get("buildId");
|
| const file = url.searchParams.get("file");
|
| const session = previewSessions.get(buildId);
|
| if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400);
|
|
|
| const pageInfo = (session.staticPages || []).find((p) => p.file === file); |
| const filePath = path.join(session.staticDir, path.basename(file)); |
| if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); |
| try { |
| let html = await fs.readFile(filePath, "utf8"); |
| const parts = splitPageHtml(html, pageInfo?.contentSelector); |
| let content = parts.contentHtml || html; |
| content = content.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`); |
| return sendJson(res, { |
| ok: true, |
| file, |
| content, |
| protectedLayout: true, |
| contentSelector: pageInfo?.contentSelector || parts.contentSelector |
| }); |
| } catch { |
| return sendJson(res, { ok: false, error: "File not found" }, 404); |
| } |
| }
|
|
|
|
|
| if (req.method === "POST" && url.pathname === "/api/page") { |
| const body = await readJson(req);
|
| const { buildId, file, content } = body;
|
| const session = previewSessions.get(buildId);
|
| if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400);
|
|
|
| let savedContent = unwrapGutenbergHtmlBlock(String(content || "")); |
| savedContent = savedContent.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}"); |
| const validationError = validateEditableHtmlFragment(savedContent); |
| if (validationError) return sendJson(res, { ok: false, error: validationError }, 400); |
|
|
| const pageInfo = (session.staticPages || []).find((p) => p.file === file); |
| if (!pageInfo) return sendJson(res, { ok: false, error: "Page metadata not found" }, 404); |
| const filePath = path.join(session.staticDir, path.basename(file)); |
| if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); |
|
|
| let fullHtml; |
| try { |
| fullHtml = await fs.readFile(filePath, "utf8"); |
| } catch { |
| return sendJson(res, { ok: false, error: "Static HTML file not found" }, 404); |
| } |
| const replacement = replaceEditableContentRegion(fullHtml, pageInfo.contentSelector, savedContent); |
| if (!replacement.ok) return sendJson(res, { ok: false, error: replacement.error }, 400); |
| await fs.writeFile(filePath, replacement.html, "utf8"); |
| const gutenbergContent = htmlToGutenberg(savedContent); |
|
|
| if (session.staticPages) { |
| const page = session.staticPages.find((p) => p.file === file); |
| if (page) page.gutenbergContent = gutenbergContent; |
| } |
|
|
| try { |
| await updateBuildManifest(buildId, (manifest) => { |
| const page = manifest.staticPages.find((p) => p.file === file); |
| if (page) page.gutenbergContent = gutenbergContent; |
| }); |
| } catch {} |
|
|
| return sendJson(res, { ok: true, protectedLayout: true, previewUrl: `/preview/${buildId}/pages/${encodeURIComponent(file)}?v=${Date.now()}` }); |
| } |
|
|
| if (req.method === "GET" && url.pathname === "/api/footer") { |
| const buildId = url.searchParams.get("buildId"); |
| const session = previewSessions.get(buildId); |
| const firstPage = session?.staticPages?.[0]; |
| if (!session || !firstPage) return sendJson(res, { ok: false, error: "Build session not found" }, 404); |
| try { |
| const filePath = path.join(session.staticDir, path.basename(firstPage.file)); |
| const html = await fs.readFile(filePath, "utf8"); |
| const footer = extractEditableFooterContent(html); |
| if (!footer.ok) return sendJson(res, { ok: false, error: footer.error }, 400); |
| const content = footer.content.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`); |
| return sendJson(res, { ok: true, content }); |
| } catch { |
| return sendJson(res, { ok: false, error: "Footer source page not found" }, 404); |
| } |
| } |
|
|
| if (req.method === "POST" && url.pathname === "/api/footer") { |
| const body = await readJson(req); |
| const { buildId, content } = body; |
| const session = previewSessions.get(buildId); |
| if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build session not found" }, 404); |
|
|
| let savedContent = unwrapGutenbergHtmlBlock(String(content || "")); |
| savedContent = savedContent.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}"); |
| const validationError = validateEditableHtmlFragment(savedContent); |
| if (validationError) return sendJson(res, { ok: false, error: validationError }, 400); |
|
|
| const targets = [ |
| ...(session.staticPages || []).map((page) => path.join(session.staticDir, path.basename(page.file))), |
| ...(session.casinoPreviewPages || []).map((page) => path.join(session.casinoPreviewDir, path.basename(page.file))) |
| ]; |
| try { |
| for (const filePath of targets) { |
| const html = await fs.readFile(filePath, "utf8"); |
| const replaced = replaceEditableFooterRegion(html, savedContent); |
| if (!replaced.ok) throw new Error(replaced.error); |
| await fs.writeFile(filePath, replaced.html, "utf8"); |
| } |
| session.footerHtml = savedContent; |
| return sendJson(res, { ok: true, previewVersion: Date.now() }); |
| } catch (error) { |
| return sendJson(res, { ok: false, error: error.message }, 400); |
| } |
| } |
|
|
| if (req.method === "GET" && url.pathname === "/api/ai/status") { |
| const buildId = url.searchParams.get("buildId"); |
| const session = buildId ? previewSessions.get(buildId) : null; |
| return sendJson(res, { |
| ok: true, |
| configured: Boolean(process.env.DEEPSEEK_API_KEY), |
| model: DEEPSEEK_MODEL, |
| available: Boolean(session?.staticPages?.length), |
| canUndo: Boolean(session?.ai?.undoStack?.length) |
| }); |
| } |
|
|
| if (req.method === "POST" && url.pathname === "/api/ai/chat") { |
| const rateLimit = consumeAiRateLimit(req); |
| if (!rateLimit.ok) { |
| res.setHeader("Retry-After", String(rateLimit.retryAfter)); |
| return sendJson(res, { ok: false, error: `AI request limit reached. Try again in ${Math.ceil(rateLimit.retryAfter / 60)} minutes.` }, 429); |
| } |
| const body = await readJson(req); |
| const session = previewSessions.get(body.buildId); |
| if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409); |
| if (!process.env.DEEPSEEK_API_KEY) return sendJson(res, { ok: false, error: "DEEPSEEK_API_KEY is not configured." }, 503); |
| const message = String(body.message || "").trim(); |
| if (!message) return sendJson(res, { ok: false, error: "Write a request for AI Review." }, 400); |
| const result = await createAiReviewProposal(body.buildId, session, message, body.activePath); |
| return sendJson(res, { ok: true, ...result }); |
| } |
|
|
| if (req.method === "POST" && url.pathname === "/api/ai/apply") { |
| const body = await readJson(req); |
| const session = previewSessions.get(body.buildId); |
| if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409); |
| const result = await applyAiProposal(body.buildId, session, String(body.proposalId || "")); |
| return sendJson(res, { ok: true, ...result }); |
| } |
|
|
| if (req.method === "POST" && url.pathname === "/api/ai/undo") { |
| const body = await readJson(req); |
| const session = previewSessions.get(body.buildId); |
| if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409); |
| const result = await undoAiProposal(session); |
| return sendJson(res, { ok: true, ...result }); |
| } |
|
|
| const requested = url.pathname === "/" ? "/index.html" : url.pathname; |
| const file = path.join(publicDir, decodeURIComponent(requested));
|
| if (!file.startsWith(publicDir)) return notFound(res);
|
| return sendFile(res, file);
|
| } catch (error) { |
| sendJson(res, { ok: false, error: error.message, ...(APP_PASSWORD ? {} : { stack: error.stack }) }, 500); |
| } |
| }); |
|
|
| const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); |
| if (isMainModule) { |
| console.log(`Starting Archive Casino WP Builder on ${host}:${port}...`); |
| server.listen(port, host, () => { |
| console.log(`Archive Casino WP Builder is running at http://${host}:${port}/`); |
| console.log(APP_PASSWORD ? `Password protection is enabled for user ${APP_USERNAME}.` : "Password protection is disabled."); |
| }); |
| } |
|
|
| function authorizeRequest(req, res) { |
| if (!APP_PASSWORD) return true; |
| const header = String(req.headers.authorization || ""); |
| if (header.startsWith("Basic ")) { |
| try { |
| const credentials = Buffer.from(header.slice(6), "base64").toString("utf8"); |
| const separator = credentials.indexOf(":"); |
| const username = separator >= 0 ? credentials.slice(0, separator) : ""; |
| const password = separator >= 0 ? credentials.slice(separator + 1) : ""; |
| if (safeEqual(username, APP_USERNAME) && safeEqual(password, APP_PASSWORD)) return true; |
| } catch {} |
| } |
| res.writeHead(401, { |
| "WWW-Authenticate": 'Basic realm="Archive Casino Builder", charset="UTF-8"', |
| "Content-Type": "text/plain; charset=utf-8", |
| "Cache-Control": "no-store" |
| }); |
| res.end("Authorization required."); |
| return false; |
| } |
|
|
| function safeEqual(left, right) { |
| const leftBuffer = Buffer.from(String(left)); |
| const rightBuffer = Buffer.from(String(right)); |
| if (leftBuffer.length !== rightBuffer.length) return false; |
| return crypto.timingSafeEqual(leftBuffer, rightBuffer); |
| } |
|
|
| function consumeAiRateLimit(req) { |
| const now = Date.now(); |
| const windowMs = 60 * 60 * 1000; |
| const forwarded = String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || ""); |
| const clientId = forwarded.split(",")[0].trim() || req.socket.remoteAddress || "unknown"; |
| const current = aiRateLimits.get(clientId); |
| const entry = !current || now - current.startedAt >= windowMs ? { startedAt: now, count: 0 } : current; |
| if (entry.count >= AI_RATE_LIMIT_PER_HOUR) { |
| return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.startedAt + windowMs - now) / 1000)) }; |
| } |
| entry.count += 1; |
| aiRateLimits.set(clientId, entry); |
| if (aiRateLimits.size > 500) { |
| for (const [key, value] of aiRateLimits) { |
| if (now - value.startedAt >= windowMs) aiRateLimits.delete(key); |
| } |
| } |
| return { ok: true }; |
| } |
|
|
| async function buildProject(input) {
|
| const config = normalizeConfig(input);
|
| const buildId = `${Date.now()}-${slugify(config.targetDomain || "site")}`;
|
| const buildDir = path.join(buildRoot, buildId);
|
| const themeDir = path.join(buildDir, slugify(config.themeName));
|
| const dataDir = path.join(themeDir, "data");
|
| const staticDir = path.join(dataDir, "static-html"); |
| const assetDir = path.join(dataDir, "archive-assets"); |
| const casinoPreviewDir = path.join(buildDir, "casino-preview"); |
| const aiReviewDir = path.join(buildDir, "ai-review"); |
| const logs = [];
|
| progressState = {
|
| active: true,
|
| buildId,
|
| updatedAt: new Date().toISOString(),
|
| logs: [],
|
| summary: { pages: 0, assets: 0, casinoPages: config.casinoPages.length }, |
| awaitingApproval: false, |
| preview: null, |
| files: [] |
| };
|
| const progressLogs = {
|
| push(message) {
|
| logs.push(message);
|
| setProgress(message, { summary: { pages: staticPages.length, assets: assetMap.size, casinoPages: config.casinoPages.length } });
|
| }
|
| };
|
|
|
| await fs.mkdir(outputsDir, { recursive: true });
|
| await fs.mkdir(staticDir, { recursive: true });
|
| await fs.mkdir(assetDir, { recursive: true }); |
| await fs.mkdir(casinoPreviewDir, { recursive: true }); |
| await fs.mkdir(aiReviewDir, { recursive: true }); |
| await fs.mkdir(themeDir, { recursive: true });
|
|
|
| const assetMap = new Map();
|
| const cssTexts = [];
|
| const staticPages = []; |
| const failedPages = []; |
| previewSessions.set(buildId, { |
| buildDir, |
| staticDir, |
| assetDir, |
| casinoPreviewDir, |
| aiReviewDir, |
| config, |
| ai: { messages: [], proposals: new Map(), undoStack: [] } |
| }); |
|
|
| if (config.crawlDomain) { |
| config.urls = await discoverArchiveUrls(config, progressLogs); |
| progressLogs.push(`Domain discovery selected ${config.urls.length} page(s) for this build.`); |
| } |
|
|
| let shellHtmlGlobal = "";
|
| let footerHtmlGlobal = "";
|
|
|
| for (const sourceUrl of config.urls) {
|
| progressLogs.push(`Resolving ${sourceUrl}`);
|
| let snapshot;
|
| try { |
| snapshot = await resolveSnapshot(sourceUrl, config); |
| } catch (resolveErr) { |
| progressLogs.push(`⚠ SKIP: не удалось найти снимок для ${sourceUrl} (${resolveErr.message}) — пропускаем`); |
| failedPages.push({ url: sourceUrl, reason: resolveErr.message }); |
| continue; |
| }
|
| progressLogs.push(`Using snapshot ${snapshot.timestamp} for ${snapshot.original}`);
|
|
|
| const htmlUrl = `https://web.archive.org/web/${snapshot.timestamp}id_/${snapshot.original}`;
|
| let rawHtml;
|
| try {
|
| rawHtml = await fetchText(htmlUrl);
|
| } catch (fetchErr) { |
| progressLogs.push(`⚠ SKIP: не удалось скачать ${snapshot.original} (${fetchErr.message}) — пропускаем`); |
| failedPages.push({ url: sourceUrl, reason: fetchErr.message }); |
| continue; |
| }
|
| let html = cleanWaybackHtml(rawHtml); |
|
|
| html = await rewriteHtmlAssets(html, snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs); |
| if (!config._reservationAssetChecked && /(?:\?rezervace|Rezervace|rozm[ií]stěn[ií] stolů)/i.test(html)) { |
| config._reservationAssetChecked = true; |
| config._reservationAssetFile = await tryDownloadReservationPlan( |
| snapshot.original, |
| snapshot.timestamp, |
| assetDir, |
| assetMap, |
| cssTexts, |
| progressLogs |
| ); |
| } |
| html = rewriteInternalSiteUrls(html, config.sourceDomain, config.targetDomain) |
| .replaceAll("{{ACI_SITE_URL}}", ""); |
|
|
|
|
| const cleaned = await cleanExternalLinks(html, config.targetDomain || config.sourceDomain, progressLogs); |
| html = cleaned.cleanedHtml; |
| html = normalizeStaticHtmlForTheme(html, config); |
| html = applySafeTextReplacements(html, config.textReplacements); |
| if (config.textReplacements.length) { |
| progressLogs.push(`Applied ${config.textReplacements.length} safe text replacement(s) without changing HTML/CSS structure.`); |
| } |
|
|
| const preparedContent = ensureEditableContentWrapper(html, config.contentSelector); |
| html = preparedContent.html; |
| const markedFooter = ensureEditableFooterMarkers(html); |
| if (!markedFooter.ok) throw new Error(`Cannot prepare editable footer for ${snapshot.original}: ${markedFooter.error}`); |
| html = markedFooter.html; |
|
|
|
|
| const parts = splitPageHtml(html, preparedContent.selector); |
| progressLogs.push(`Split: контент через "${parts.contentSelector}", content=${parts.contentHtml.length}b, shell=${parts.shellHtml.length}b`);
|
|
|
|
|
| if (!shellHtmlGlobal && parts.shellHtml) {
|
| shellHtmlGlobal = parts.shellHtml;
|
| footerHtmlGlobal = parts.footerHtml;
|
| }
|
|
|
|
|
| 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`;
|
|
|
|
|
| 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,
|
| contentSelector: parts.contentSelector
|
| });
|
| progressLogs.push(`Static page ready: ${requestPath}`); |
| } |
|
|
| if (config.crawlDomain && failedPages.length && staticPages.length) { |
| progressLogs.push(`Domain discovery build skipped ${failedPages.length} unavailable page(s) and kept ${staticPages.length} recovered page(s).`); |
| } |
| if (!staticPages.length || (!config.crawlDomain && (failedPages.length || staticPages.length !== config.urls.length))) { |
| const failedList = failedPages.map((item) => `${item.url}: ${item.reason}`).join("\n"); |
| const error = new Error( |
| `Сборка остановлена: скачано ${staticPages.length} из ${config.urls.length} страниц. ` + |
| `ZIP не создан, чтобы WordPress не получил пустую или неполную тему.\n${failedList}` |
| ); |
| progressState.active = false; |
| progressState.awaitingApproval = false; |
| progressState.updatedAt = new Date().toISOString(); |
| progressState.error = error.message; |
| progressLogs.push(error.message); |
| throw error; |
| } |
|
|
|
|
| config._shellHtml = shellHtmlGlobal; |
| config._footerHtml = footerHtmlGlobal; |
|
|
| const casinoPreviewPages = await writeCasinoPreviewPages(casinoPreviewDir, staticDir, staticPages, config); |
| progressLogs.push(`Styled Casino preview ready with ${casinoPreviewPages.length} Gutenberg page(s).`); |
| const routeMap = new Map(); |
| for (const page of staticPages) { |
| routeMap.set(page.path.toLowerCase(), `/preview/${buildId}/pages/${encodeURIComponent(page.file)}`); |
| } |
| for (const page of casinoPreviewPages) { |
| routeMap.set(page.path.toLowerCase(), `/preview/${buildId}/casino/${encodeURIComponent(page.file)}`); |
| } |
|
|
|
|
|
|
| const session = previewSessions.get(buildId);
|
| if (session) { |
| session.staticPages = staticPages; |
| session.casinoPreviewPages = casinoPreviewPages; |
| session.routeMap = routeMap; |
| const firstFooter = staticPages.length |
| ? extractEditableFooterContent(await fs.readFile(path.join(staticDir, staticPages[0].file), "utf8")) |
| : null; |
| session.footerHtml = firstFooter?.ok ? firstFooter.content : ""; |
| } |
|
|
| const design = extractDesign(cssTexts.join("\n"));
|
| const designAssets = extractDesignAssets(assetMap, cssTexts);
|
| const manifest = { |
| generatedAt: new Date().toISOString(),
|
| sourceDomain: config.sourceDomain,
|
| targetDomain: config.targetDomain,
|
| staticPages,
|
| menuItems: config.menuItems,
|
| casino: {
|
| menuLabel: config.casinoMenuLabel,
|
| rootPath: config.casinoRootPath,
|
| pages: config.casinoPages
|
| },
|
| design,
|
| designAssets |
| }; |
| if (session) session.manifest = manifest; |
|
|
| const previewPages = staticPages.map((page) => ({ |
| title: page.title, |
| path: page.path, |
| kind: "static", |
| url: `/preview/${buildId}/pages/${encodeURIComponent(page.file)}` |
| })).concat(casinoPreviewPages.map((page) => ({ |
| title: page.title, |
| path: page.path, |
| kind: "casino", |
| url: `/preview/${buildId}/casino/${encodeURIComponent(page.file)}` |
| }))); |
| progressState.awaitingApproval = true;
|
| progressState.preview = { buildId, pages: previewPages };
|
| progressState.updatedAt = new Date().toISOString();
|
| progressLogs.push("Preview ready. Review pages and click Continue build.");
|
| await waitForPreviewApproval(buildId);
|
| progressLogs.push("Preview approved. Building WordPress package.");
|
|
|
| await fs.writeFile(path.join(dataDir, "pages.json"), JSON.stringify(manifest, null, 2), "utf8");
|
| await writeTheme(themeDir, config, manifest);
|
| progressLogs.push("Theme-only WordPress package generated");
|
|
|
| const themeZipName = `${slugify(config.themeName)}.zip`;
|
| const reportName = `archive-build-report-${buildId}.html`;
|
|
|
| await zipDirectory(themeDir, path.join(outputsDir, themeZipName), { includeRoot: false });
|
| progressLogs.push(`ZIP ready: ${themeZipName}`);
|
| await writeReport(path.join(outputsDir, reportName), manifest, logs, assetMap);
|
| progressLogs.push(`Report ready: ${reportName}`);
|
|
|
| progressState.active = false;
|
| progressState.updatedAt = new Date().toISOString();
|
| progressState.summary = { |
| pages: staticPages.length, |
| assets: assetMap.size, |
| casinoPages: config.casinoPages.length |
| }; |
| progressState.files = [ |
| { name: themeZipName, url: `/download/${themeZipName}` }, |
| { name: reportName, url: `/download/${reportName}` } |
| ]; |
|
|
| return { |
| ok: true,
|
| buildId,
|
| files: progressState.files, |
| summary: {
|
| pages: staticPages.length,
|
| assets: assetMap.size,
|
| casinoPages: config.casinoPages.length
|
| },
|
| logs
|
| }; |
| } |
|
|
| async function writeCasinoPreviewPages(previewDir, staticDir, staticPages, config) { |
| if (!staticPages.length) return []; |
| const basePage = staticPages[0]; |
| const baseHtml = await fs.readFile(path.join(staticDir, basePage.file), "utf8"); |
| const previews = []; |
| for (const page of config.casinoPages) { |
| const content = page.previewHtml || casinoDemoContent(page.title); |
| const replaced = replaceEditableContentRegion(baseHtml, basePage.contentSelector, content); |
| if (!replaced.ok) throw new Error(`Cannot create styled Casino preview for ${page.path}: ${replaced.error}`); |
| const file = `casino-${slugify(page.slug || page.title)}.html`; |
| const html = replaced.html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, `<title>${escapeHtml(page.title)}</title>`); |
| await fs.writeFile(path.join(previewDir, file), html, "utf8"); |
| previews.push({ ...page, file }); |
| } |
| return previews; |
| } |
|
|
| function ensureAiState(session) { |
| if (!session.ai) session.ai = { messages: [], proposals: new Map(), undoStack: [] }; |
| if (!(session.ai.proposals instanceof Map)) session.ai.proposals = new Map(); |
| if (!Array.isArray(session.ai.messages)) session.ai.messages = []; |
| if (!Array.isArray(session.ai.undoStack)) session.ai.undoStack = []; |
| return session.ai; |
| } |
|
|
| async function createAiReviewProposal(buildId, session, message, activePath) { |
| const ai = ensureAiState(session); |
| const selectedPath = normalizeRequestPath(activePath || session.staticPages[0]?.path || "/"); |
| const diagnostics = await collectAiDiagnostics(session, selectedPath); |
| let visual; |
| try { |
| visual = await runVisualComparison(buildId, session, selectedPath); |
| } catch (error) { |
| visual = { available: false, error: `Visual comparison unavailable: ${error.message}` }; |
| } |
| const context = await buildAiReviewContext(session, selectedPath, diagnostics, visual); |
| const proposal = sanitizeAiProposal(await requestDeepSeekProposal(ai.messages, message, context)); |
| const proposalId = crypto.randomUUID(); |
| const stored = { ...proposal, aiDiagnostics: proposal.diagnostics, proposalId, diagnostics, visual, createdAt: new Date().toISOString() }; |
| ai.proposals.set(proposalId, stored); |
| ai.messages.push({ role: "user", content: message }, { role: "assistant", content: proposal.reply || proposal.summary }); |
| if (ai.messages.length > 12) ai.messages = ai.messages.slice(-12); |
| return stored; |
| } |
|
|
| async function collectAiDiagnostics(session, activePath) { |
| const diagnostics = []; |
| const allPages = [ |
| ...(session.staticPages || []).map((page) => ({ ...page, kind: "static", dir: session.staticDir })), |
| ...(session.casinoPreviewPages || []).map((page) => ({ ...page, kind: "casino", dir: session.casinoPreviewDir })) |
| ]; |
| let totalImages = 0; |
| let missingImages = 0; |
| for (const page of allPages) { |
| const filePath = path.join(page.dir, path.basename(page.file)); |
| const html = await fs.readFile(filePath, "utf8"); |
| const imageRefs = [...html.matchAll(/<img\b[^>]*\bsrc=(['"])([^'"]+)\1/gi)].map((match) => match[2]); |
| totalImages += imageRefs.length; |
| for (const ref of imageRefs) { |
| const local = /\{\{ACI_ASSET_URL\}\}\/([^\s"'?#<>]+)/.exec(ref); |
| if (local) { |
| try { await fs.access(path.join(session.assetDir, path.basename(local[1]))); } |
| catch { missingImages++; diagnostics.push({ severity: "error", title: "Missing image", detail: path.basename(local[1]), path: page.path }); } |
| } else if (/^https?:\/\//i.test(ref)) { |
| diagnostics.push({ severity: "warning", title: "External image", detail: ref.slice(0, 220), path: page.path }); |
| } |
| } |
| const contentRegion = locateEditableContentRegion(html, page.contentSelector || session.staticPages[0]?.contentSelector); |
| if (!contentRegion) diagnostics.push({ severity: "error", title: "Editable content not detected", detail: page.contentSelector || "automatic", path: page.path }); |
| if (!extractEditableFooterContent(html).ok) diagnostics.push({ severity: "warning", title: "Footer not detected", detail: "A fallback footer will be used.", path: page.path }); |
| if (/\{\{ACI_(?:ASSET|SITE)_URL\}\}(?![\/])/i.test(html)) diagnostics.push({ severity: "warning", title: "Suspicious unresolved placeholder", detail: "Review asset or site URL placeholders.", path: page.path }); |
| } |
| const failedLogs = (progressState.logs || []).filter((line) => /(?:unavailable|missing|failed|error|404|skip)/i.test(line)).slice(-12); |
| for (const line of failedLogs) diagnostics.push({ severity: "warning", title: "Build log", detail: line.slice(0, 400), path: activePath }); |
| if (!missingImages) diagnostics.push({ severity: "ok", title: "Local images", detail: `${totalImages} image references checked; no missing local files found.`, path: activePath }); |
| if (!diagnostics.some((item) => item.severity === "error")) diagnostics.unshift({ severity: "ok", title: "Page structure", detail: "Editable content and footer regions are available.", path: activePath }); |
| return diagnostics.slice(0, 30); |
| } |
|
|
| async function buildAiReviewContext(session, activePath, diagnostics, visual) { |
| const staticPage = (session.staticPages || []).find((page) => normalizeRequestPath(page.path) === activePath); |
| const casinoPage = (session.casinoPreviewPages || []).find((page) => normalizeRequestPath(page.path) === activePath); |
| const selected = staticPage || casinoPage || session.staticPages[0]; |
| const selectedDir = casinoPage ? session.casinoPreviewDir : session.staticDir; |
| const html = await fs.readFile(path.join(selectedDir, path.basename(selected.file)), "utf8"); |
| const selector = selected.contentSelector || session.staticPages[0]?.contentSelector; |
| const region = locateEditableContentRegion(html, selector); |
| const content = region ? html.slice(region.innerStart, region.innerEnd) : html; |
| const footer = extractEditableFooterContent(html); |
| return JSON.stringify({ |
| activePage: { title: selected.title, path: selected.path, kind: casinoPage ? "casino" : "static", contentSelector: selector }, |
| pages: [ |
| ...(session.staticPages || []).map((page) => ({ title: page.title, path: page.path, kind: "static", selector: page.contentSelector })), |
| ...(session.casinoPreviewPages || []).map((page) => ({ title: page.title, path: page.path, kind: "casino" })) |
| ], |
| menuItems: session.config?.menuItems || [], |
| casinoPages: session.config?.casinoPages || [], |
| footerHtml: (footer.ok ? footer.content : "").slice(0, 8000), |
| contentHtml: content.slice(0, 18000), |
| diagnostics, |
| visual |
| }); |
| } |
|
|
| async function requestDeepSeekProposal(history, userMessage, context) { |
| const operationSchema = { |
| type: "object", |
| properties: { |
| type: { type: "string", enum: ["replace_text", "set_footer_html", "set_page_content", "append_css", "set_menu_items", "set_content_selector"] }, |
| path: { type: "string" }, |
| scope: { type: "string", enum: ["all", "static", "casino"] }, |
| find: { type: "string" }, |
| replace: { type: "string" }, |
| html: { type: "string" }, |
| css: { type: "string" }, |
| selector: { type: "string" }, |
| items: { type: "array", items: { type: "object", properties: { title: { type: "string" }, path: { type: "string" } }, required: ["title", "path"] } } |
| }, |
| required: ["type"] |
| }; |
| const tools = [{ |
| type: "function", |
| function: { |
| name: "propose_preview_changes", |
| description: "Return a review and a limited list of safe changes. Changes are shown to the user and are not applied automatically.", |
| parameters: { |
| type: "object", |
| properties: { |
| reply: { type: "string" }, |
| summary: { type: "string" }, |
| diagnostics: { type: "array", items: { type: "string" } }, |
| risks: { type: "array", items: { type: "string" } }, |
| operations: { type: "array", items: operationSchema } |
| }, |
| required: ["reply", "summary", "diagnostics", "risks", "operations"] |
| } |
| } |
| }]; |
| const system = [ |
| "You are the AI Review assistant inside a local Wayback-to-WordPress theme builder.", |
| "Reply in the user's language. Diagnose clearly and propose only necessary operations.", |
| "Never invent recovered facts or missing images. Never add scripts, trackers, affiliate links, remote assets, PHP, or WordPress credentials.", |
| "Use set_page_content only for an existing path. Preserve the site's structural classes when rewriting HTML.", |
| "Use append_css for visual adaptation, set_footer_html for the global footer, and set_menu_items only when the user explicitly asks about navigation.", |
| "Casino article content should be semantic HTML suitable for conversion to Gutenberg. Do not add unverifiable casino ratings or claims.", |
| "If the user asks only for analysis, return an empty operations array." |
| ].join(" "); |
| const messages = [ |
| { role: "system", content: system }, |
| ...history.slice(-8), |
| { role: "user", content: `BUILD CONTEXT:\n${context}\n\nUSER REQUEST:\n${userMessage}` } |
| ]; |
| const response = await fetch(DEEPSEEK_API_URL, { |
| method: "POST", |
| headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}` }, |
| body: JSON.stringify({ model: DEEPSEEK_MODEL, messages, tools, tool_choice: { type: "function", function: { name: "propose_preview_changes" } }, thinking: { type: "disabled" }, max_tokens: 5000 }), |
| signal: AbortSignal.timeout(90000) |
| }); |
| const payload = await response.json().catch(() => ({})); |
| if (!response.ok) throw new Error(`DeepSeek API ${response.status}: ${payload.error?.message || "request failed"}`); |
| const call = payload.choices?.[0]?.message?.tool_calls?.find((item) => item.function?.name === "propose_preview_changes"); |
| if (!call?.function?.arguments) throw new Error("DeepSeek returned no structured review proposal."); |
| try { return JSON.parse(call.function.arguments); } |
| catch { throw new Error("DeepSeek returned invalid proposal JSON."); } |
| } |
|
|
| function sanitizeAiProposal(raw) { |
| const allowed = new Set(["replace_text", "set_footer_html", "set_page_content", "append_css", "set_menu_items", "set_content_selector"]); |
| const operations = []; |
| for (const source of Array.isArray(raw?.operations) ? raw.operations.slice(0, 12) : []) { |
| if (!allowed.has(source?.type)) continue; |
| const operation = { |
| type: source.type, |
| path: normalizeRequestPath(source.path || "/"), |
| scope: ["all", "static", "casino"].includes(source.scope) ? source.scope : "all", |
| find: String(source.find || "").slice(0, 2000), |
| replace: String(source.replace || "").slice(0, 10000), |
| html: String(source.html || "").slice(0, 150000), |
| css: String(source.css || "").slice(0, 50000), |
| selector: String(source.selector || "").trim().replace(/^\./, "").slice(0, 120), |
| items: Array.isArray(source.items) ? source.items.slice(0, 30).map((item) => ({ title: String(item.title || "").slice(0, 120), path: normalizeRequestPath(item.path || "/") })).filter((item) => item.title) : [] |
| }; |
| if ((operation.type === "set_footer_html" || operation.type === "set_page_content") && validateEditableHtmlFragment(operation.html)) continue; |
| if (operation.type === "append_css" && /(?:<\/style|@import|javascript:|expression\s*\()/i.test(operation.css)) continue; |
| if (operation.type === "set_content_selector" && !/^[a-zA-Z0-9_-]+$/.test(operation.selector)) continue; |
| operations.push(operation); |
| } |
| return { |
| reply: String(raw?.reply || raw?.summary || "Review ready.").slice(0, 8000), |
| summary: String(raw?.summary || "AI Review proposal").slice(0, 500), |
| diagnostics: Array.isArray(raw?.diagnostics) ? raw.diagnostics.slice(0, 20).map((item) => String(item).slice(0, 500)) : [], |
| risks: Array.isArray(raw?.risks) ? raw.risks.slice(0, 12).map((item) => String(item).slice(0, 500)) : [], |
| operations |
| }; |
| } |
|
|
| async function applyAiProposal(buildId, session, proposalId) { |
| const ai = ensureAiState(session); |
| const proposal = ai.proposals.get(proposalId); |
| if (!proposal) throw new Error("AI proposal was not found or was already applied."); |
| const backup = await snapshotAiState(session, proposal.summary); |
| try { |
| for (const operation of proposal.operations) await applyAiOperation(session, operation); |
| } catch (error) { |
| await restoreAiState(session, backup); |
| throw error; |
| } |
| ai.undoStack.push(backup); |
| if (ai.undoStack.length > 8) ai.undoStack.shift(); |
| ai.proposals.delete(proposalId); |
| const previewVersion = Date.now(); |
| return { applied: proposal.operations.length, summary: proposal.summary, previewVersion, canUndo: true }; |
| } |
|
|
| async function applyAiOperation(session, operation) { |
| const staticTargets = (session.staticPages || []).map((page) => ({ page, dir: session.staticDir, kind: "static" })); |
| const casinoTargets = (session.casinoPreviewPages || []).map((page) => ({ page, dir: session.casinoPreviewDir, kind: "casino" })); |
| const allTargets = operation.scope === "static" ? staticTargets : operation.scope === "casino" ? casinoTargets : [...staticTargets, ...casinoTargets]; |
|
|
| if (operation.type === "replace_text") { |
| if (!operation.find) throw new Error("AI replace_text operation has no source text."); |
| for (const target of allTargets) { |
| const filePath = path.join(target.dir, path.basename(target.page.file)); |
| const html = await fs.readFile(filePath, "utf8"); |
| await fs.writeFile(filePath, applySafeTextReplacements(html, [{ from: operation.find, to: operation.replace }]), "utf8"); |
| if (target.page.gutenbergContent) target.page.gutenbergContent = applySafeTextReplacements(target.page.gutenbergContent, [{ from: operation.find, to: operation.replace }]); |
| const manifestPage = target.kind === "static" |
| ? session.manifest?.staticPages?.find((page) => normalizeRequestPath(page.path) === normalizeRequestPath(target.page.path)) |
| : session.manifest?.casino?.pages?.find((page) => normalizeRequestPath(page.path) === normalizeRequestPath(target.page.path)); |
| if (manifestPage && target.page.gutenbergContent) manifestPage.gutenbergContent = target.page.gutenbergContent; |
| } |
| return; |
| } |
|
|
| if (operation.type === "set_footer_html") { |
| if (validateEditableHtmlFragment(operation.html)) throw new Error("AI footer HTML did not pass validation."); |
| for (const target of [...staticTargets, ...casinoTargets]) { |
| const filePath = path.join(target.dir, path.basename(target.page.file)); |
| const replaced = replaceEditableFooterRegion(await fs.readFile(filePath, "utf8"), operation.html); |
| if (!replaced.ok) throw new Error(replaced.error); |
| await fs.writeFile(filePath, replaced.html, "utf8"); |
| } |
| session.footerHtml = operation.html; |
| if (session.manifest) session.manifest.footer = { html: operation.html }; |
| return; |
| } |
|
|
| if (operation.type === "set_page_content") { |
| const target = [...staticTargets, ...casinoTargets].find((item) => normalizeRequestPath(item.page.path) === operation.path); |
| if (!target) throw new Error(`AI target page does not exist: ${operation.path}`); |
| if (validateEditableHtmlFragment(operation.html)) throw new Error("AI page HTML did not pass validation."); |
| const filePath = path.join(target.dir, path.basename(target.page.file)); |
| const selector = target.page.contentSelector || session.staticPages[0]?.contentSelector; |
| const replaced = replaceEditableContentRegion(await fs.readFile(filePath, "utf8"), selector, operation.html); |
| if (!replaced.ok) throw new Error(replaced.error); |
| await fs.writeFile(filePath, replaced.html, "utf8"); |
| target.page.gutenbergContent = htmlToGutenberg(operation.html); |
| const manifestPage = target.kind === "static" |
| ? session.manifest?.staticPages?.find((page) => normalizeRequestPath(page.path) === operation.path) |
| : session.manifest?.casino?.pages?.find((page) => normalizeRequestPath(page.path) === operation.path); |
| if (manifestPage) manifestPage.gutenbergContent = target.page.gutenbergContent; |
| return; |
| } |
|
|
| if (operation.type === "append_css") { |
| if (!operation.css || /(?:<\/style|@import|javascript:|expression\s*\()/i.test(operation.css)) throw new Error("AI CSS did not pass validation."); |
| const previous = String(session.manifest?.aiCustomCss || ""); |
| const combined = [previous, operation.css].filter(Boolean).join("\n").slice(-100000); |
| if (session.manifest) session.manifest.aiCustomCss = combined; |
| for (const target of [...staticTargets, ...casinoTargets]) { |
| const filePath = path.join(target.dir, path.basename(target.page.file)); |
| await fs.writeFile(filePath, injectAiCustomCss(await fs.readFile(filePath, "utf8"), combined), "utf8"); |
| } |
| return; |
| } |
|
|
| if (operation.type === "set_menu_items") { |
| if (!operation.items.length) throw new Error("AI menu operation has no items."); |
| session.config.menuItems = operation.items; |
| if (session.manifest) session.manifest.menuItems = operation.items; |
| for (const target of [...staticTargets, ...casinoTargets]) { |
| const filePath = path.join(target.dir, path.basename(target.page.file)); |
| await fs.writeFile(filePath, pruneStaticMenus(await fs.readFile(filePath, "utf8"), session.config), "utf8"); |
| } |
| return; |
| } |
|
|
| if (operation.type === "set_content_selector") { |
| const target = staticTargets.find((item) => normalizeRequestPath(item.page.path) === operation.path); |
| if (!target) throw new Error(`Static page does not exist: ${operation.path}`); |
| const filePath = path.join(target.dir, path.basename(target.page.file)); |
| const html = await fs.readFile(filePath, "utf8"); |
| const region = locateEditableContentRegion(html, operation.selector); |
| if (!region || region.selector !== operation.selector) throw new Error(`Content selector was not found: ${operation.selector}`); |
| target.page.contentSelector = operation.selector; |
| target.page.gutenbergContent = htmlToGutenberg(html.slice(region.innerStart, region.innerEnd)); |
| const manifestPage = session.manifest?.staticPages?.find((page) => normalizeRequestPath(page.path) === operation.path); |
| if (manifestPage) Object.assign(manifestPage, { contentSelector: operation.selector, gutenbergContent: target.page.gutenbergContent }); |
| } |
| } |
|
|
| function injectAiCustomCss(html, css) { |
| const style = `<style id="acwpb-ai-custom-css">\n${css}\n</style>`; |
| if (/<style\b[^>]*id=["']acwpb-ai-custom-css["'][^>]*>[\s\S]*?<\/style>/i.test(html)) { |
| return html.replace(/<style\b[^>]*id=["']acwpb-ai-custom-css["'][^>]*>[\s\S]*?<\/style>/i, style); |
| } |
| return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, style + "\n</head>") : style + html; |
| } |
|
|
| async function snapshotAiState(session, label) { |
| const targets = [ |
| ...(session.staticPages || []).map((page) => path.join(session.staticDir, path.basename(page.file))), |
| ...(session.casinoPreviewPages || []).map((page) => path.join(session.casinoPreviewDir, path.basename(page.file))) |
| ]; |
| const files = []; |
| for (const filePath of targets) files.push({ filePath, html: await fs.readFile(filePath, "utf8") }); |
| return { |
| label, |
| files, |
| staticPages: structuredClone(session.staticPages || []), |
| casinoPreviewPages: structuredClone(session.casinoPreviewPages || []), |
| menuItems: structuredClone(session.config?.menuItems || []), |
| casinoPages: structuredClone(session.config?.casinoPages || []), |
| footerHtml: session.footerHtml || "", |
| aiCustomCss: session.manifest?.aiCustomCss || "" |
| }; |
| } |
|
|
| async function restoreAiState(session, snapshot) { |
| for (const file of snapshot.files) await fs.writeFile(file.filePath, file.html, "utf8"); |
| session.staticPages = structuredClone(snapshot.staticPages); |
| session.casinoPreviewPages = structuredClone(snapshot.casinoPreviewPages); |
| session.config.menuItems = structuredClone(snapshot.menuItems); |
| session.config.casinoPages = structuredClone(snapshot.casinoPages); |
| session.footerHtml = snapshot.footerHtml; |
| if (session.manifest) { |
| session.manifest.staticPages = session.staticPages; |
| session.manifest.menuItems = session.config.menuItems; |
| session.manifest.casino.pages = session.config.casinoPages; |
| session.manifest.footer = { html: snapshot.footerHtml }; |
| session.manifest.aiCustomCss = snapshot.aiCustomCss; |
| } |
| } |
|
|
| async function undoAiProposal(session) { |
| const ai = ensureAiState(session); |
| const snapshot = ai.undoStack.pop(); |
| if (!snapshot) throw new Error("There are no AI changes to undo."); |
| await restoreAiState(session, snapshot); |
| return { undone: snapshot.label, previewVersion: Date.now(), canUndo: ai.undoStack.length > 0 }; |
| } |
|
|
| async function runVisualComparison(buildId, session, activePath) { |
| if (!(await fileExists(EDGE_PATH))) return { available: false, error: "Microsoft Edge was not found for screenshots." }; |
| const selectedStatic = (session.staticPages || []).find((page) => normalizeRequestPath(page.path) === activePath); |
| const selectedCasino = (session.casinoPreviewPages || []).find((page) => normalizeRequestPath(page.path) === activePath); |
| const sourcePage = selectedStatic || session.staticPages[0]; |
| const previewPage = selectedStatic || selectedCasino || sourcePage; |
| const previewKind = selectedCasino ? "casino" : "pages"; |
| const key = slugify(previewPage.path || previewPage.title || "page"); |
| const originalFile = path.join(session.aiReviewDir, `${key}-original.png`); |
| const previewFile = path.join(session.aiReviewDir, `${key}-preview.png`); |
| const diffFile = path.join(session.aiReviewDir, `${key}-diff.png`); |
| const originalUrl = `https://web.archive.org/web/${sourcePage.timestamp}id_/${sourcePage.sourceUrl}`; |
| const previewUrl = `http://localhost:${port}/preview/${buildId}/${previewKind}/${encodeURIComponent(previewPage.file)}`; |
| await captureEdgeScreenshot(originalUrl, originalFile, `${key}-original`); |
| await captureEdgeScreenshot(previewUrl, previewFile, `${key}-preview`); |
| const width = 1100; |
| const height = 900; |
| const [original, preview] = await Promise.all([ |
| sharp(originalFile).resize(width, height, { fit: "fill" }).removeAlpha().raw().toBuffer(), |
| sharp(previewFile).resize(width, height, { fit: "fill" }).removeAlpha().raw().toBuffer() |
| ]); |
| const diff = Buffer.alloc(width * height * 3); |
| let delta = 0; |
| for (let index = 0; index < diff.length; index++) { |
| const value = Math.abs(original[index] - preview[index]); |
| delta += value; |
| diff[index] = Math.min(255, value * 3); |
| } |
| await sharp(diff, { raw: { width, height, channels: 3 } }).png().toFile(diffFile); |
| const meanDifference = delta / diff.length / 255; |
| return { |
| available: true, |
| comparedPath: previewPage.path, |
| sourcePath: sourcePage.path, |
| similarity: Math.max(0, Math.round((1 - meanDifference) * 1000) / 10), |
| originalUrl: `/preview/${buildId}/ai/${path.basename(originalFile)}`, |
| previewUrl: `/preview/${buildId}/ai/${path.basename(previewFile)}`, |
| diffUrl: `/preview/${buildId}/ai/${path.basename(diffFile)}` |
| }; |
| } |
|
|
| async function captureEdgeScreenshot(url, destination, profileName) { |
| const profile = path.join(path.dirname(destination), `edge-${profileName}`); |
| await runChildProcess(EDGE_PATH, [ |
| "--headless=new", |
| "--disable-gpu", |
| "--hide-scrollbars", |
| `--user-data-dir=${profile}`, |
| "--window-size=1440,1200", |
| "--virtual-time-budget=12000", |
| `--screenshot=${destination}`, |
| url |
| ], 45000); |
| if (!(await fileExists(destination))) throw new Error("Edge did not create a screenshot."); |
| } |
|
|
| function runChildProcess(command, args, timeoutMs) { |
| return new Promise((resolve, reject) => { |
| const child = spawn(command, args, { windowsHide: true, stdio: "ignore" }); |
| const timer = setTimeout(() => { child.kill(); reject(new Error("Screenshot process timed out.")); }, timeoutMs); |
| child.once("error", (error) => { clearTimeout(timer); reject(error); }); |
| child.once("exit", (code) => { |
| clearTimeout(timer); |
| code === 0 ? resolve() : reject(new Error(`Screenshot process exited with code ${code}.`)); |
| }); |
| }); |
| } |
|
|
| async function fileExists(file) { |
| try { await fs.access(file); return true; } |
| catch { return false; } |
| } |
|
|
| function setProgress(message, patch = {}) { |
| const time = new Date().toLocaleTimeString("ru-RU", { hour12: false });
|
| progressState.logs.push(`[${time}] ${message}`);
|
| if (progressState.logs.length > 500) progressState.logs.shift();
|
| progressState = {
|
| ...progressState,
|
| ...patch,
|
| updatedAt: new Date().toISOString()
|
| };
|
| }
|
|
|
| function waitForPreviewApproval(buildId) {
|
| return new Promise((resolve) => {
|
| approvalWaiters.set(buildId, resolve);
|
| });
|
| }
|
|
|
|
|
| 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; }
|
| }
|
|
|
|
|
| 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) { }
|
| }
|
|
|
|
|
| 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") {
|
|
|
| try {
|
| require("fs").accessSync(path.join(buildDir, e.name, "style.css"));
|
| return e.name;
|
| } catch {}
|
| }
|
| }
|
| } catch {}
|
|
|
| try {
|
| const entries = require("fs").readdirSync(buildDir, { withFileTypes: true });
|
| for (const e of entries) {
|
| if (e.isDirectory()) {
|
| try {
|
| require("fs").accessSync(path.join(buildDir, e.name, "data", "pages.json"));
|
| return e.name;
|
| } catch {}
|
| }
|
| }
|
| } catch {}
|
| return "";
|
| }
|
|
|
| async function sendPreviewFile(res, pathname) { |
| const parts = pathname.split("/").filter(Boolean);
|
| const [, buildId, kind, rawName] = parts;
|
| const session = previewSessions.get(buildId);
|
| if (!session || !rawName) return notFound(res);
|
|
|
| const name = path.basename(decodeURIComponent(rawName));
|
| if (kind === "pages" || kind === "casino") { |
| const baseDir = kind === "casino" ? session.casinoPreviewDir : session.staticDir; |
| if (!baseDir) return notFound(res); |
| const file = path.join(baseDir, name); |
| if (!file.startsWith(baseDir)) return notFound(res); |
| try { |
| const html = await fs.readFile(file, "utf8"); |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); |
| const rendered = html |
| .replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`) |
| .replaceAll("{{ACI_SITE_URL}}", "/"); |
| res.end(rewritePreviewNavigation(rendered, session.routeMap)); |
| } catch {
|
| notFound(res);
|
| }
|
| return; |
| } |
|
|
| if (kind === "ai") { |
| const file = path.join(session.aiReviewDir || "", name); |
| if (!session.aiReviewDir || !file.startsWith(session.aiReviewDir)) return notFound(res); |
| return sendFile(res, file); |
| } |
|
|
| if (kind === "assets") { |
| const file = path.join(session.assetDir, name);
|
| if (!file.startsWith(session.assetDir)) return notFound(res);
|
| return sendFile(res, file);
|
| }
|
|
|
| return notFound(res); |
| } |
|
|
| function rewritePreviewNavigation(html, routeMap) { |
| if (!(routeMap instanceof Map) || routeMap.size === 0) return html; |
| return html.replace(/\bhref=(['"])([^'"]+)\1/gi, (whole, quote, raw) => { |
| if (!raw || /^(?:#|mailto:|tel:|javascript:)/i.test(raw)) return whole; |
| try { |
| const parsed = new URL(raw, "https://preview.local/"); |
| const key = normalizeRequestPath(parsed.pathname).toLowerCase(); |
| const target = routeMap.get(key); |
| return target ? `href=${quote}${target}${quote}` : whole; |
| } catch { |
| return whole; |
| } |
| }); |
| } |
|
|
| const DEFAULT_STATIC_MENU = "/cz/1-O-restauraci/ | O restauraci\n/cz/9-Poledni-menu/ | Polední menu\n/cz/15-Fotogalerie/ | Fotogalerie\n/cz/17-Kontakty/ | Kontakty";
|
|
|
| function parseMenuItems(value) { |
| const source = value === undefined || value === null ? DEFAULT_STATIC_MENU : value; |
| return String(source) |
| .split(/\r?\n/)
|
| .map((line) => line.trim())
|
| .filter(Boolean)
|
| .map((line) => {
|
| const parts = line.split("|").map((part) => part.trim()).filter(Boolean);
|
| let pathValue = parts.length >= 2 ? parts[0] : line;
|
| let title = parts.length >= 2 ? parts.slice(1).join(" | ") : titleFromPath(pathValue);
|
| pathValue = pathValue.replace(/^https?:\/\/[^/]+/i, "");
|
| if (!pathValue.startsWith("/")) pathValue = "/" + pathValue;
|
| if (!pathValue.endsWith("/")) pathValue += "/";
|
| return { title: title || pathValue, path: pathValue.replace(/\/+/g, "/") };
|
| });
|
| }
|
|
|
| function parseTextReplacements(value) { |
| return String(value || "") |
| .split(/\r?\n/) |
| .map((line) => line.trim()) |
| .filter(Boolean) |
| .map((line, index) => { |
| const separator = line.indexOf("=>"); |
| if (separator < 1) throw new Error(`Text replacement line ${index + 1} must use: old text => new text`); |
| const from = line.slice(0, separator).trim(); |
| const to = line.slice(separator + 2).trim(); |
| if (!from) throw new Error(`Text replacement line ${index + 1} has an empty source value.`); |
| if (/[<>]/.test(from + to)) throw new Error(`Text replacement line ${index + 1} cannot contain HTML brackets.`); |
| return { from, to }; |
| }); |
| } |
|
|
| function applySafeTextReplacements(html, replacements) { |
| if (!Array.isArray(replacements) || replacements.length === 0) return String(html || ""); |
|
|
| const protectedBlocks = []; |
| let protectedHtml = String(html || "").replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, (block) => { |
| const token = `\u0000ACWPB_BLOCK_${protectedBlocks.length}\u0000`; |
| protectedBlocks.push(block); |
| return token; |
| }); |
| const replaceValue = (value) => { |
| let result = value; |
| for (const { from, to } of replacements) result = result.split(from).join(to); |
| return result; |
| }; |
|
|
| protectedHtml = protectedHtml.replace(/<[^>]+>|[^<]+/g, (part) => { |
| if (!part.startsWith("<")) return replaceValue(part); |
| if (/^<!--/.test(part)) return part; |
| return part.replace(/\b(alt|title|aria-label|placeholder|value|href)=(['"])([\s\S]*?)\2/gi, |
| (attribute, name, quote, value) => { |
| if (name.toLowerCase() === "href" && !/^(?:mailto|tel):/i.test(value)) return attribute; |
| return `${name}=${quote}${replaceValue(value)}${quote}`; |
| }); |
| }); |
|
|
| return protectedHtml.replace(/\u0000ACWPB_BLOCK_(\d+)\u0000/g, (_token, index) => protectedBlocks[Number(index)] || ""); |
| } |
|
|
| const DEFAULT_CASINO_PAGES = "/casino/best-casinos/ | Best Casinos\n/casino/neteller-casino/ | Neteller Casino\n/casino/skrill-casino/ | Skrill Casino"; |
|
|
| function parseCasinoPages(value, nestedCasino = true) {
|
| const lines = String(value || DEFAULT_CASINO_PAGES)
|
| .split(/\r?\n/)
|
| .map((line) => line.trim())
|
| .filter(Boolean);
|
|
|
| const pages = lines.map((line, index) => parseCasinoPageLine(line, index, nestedCasino));
|
|
|
|
|
|
|
| const seen = new Set();
|
| return pages.filter((page) => {
|
| if (seen.has(page.path)) return false;
|
| seen.add(page.path);
|
| return true;
|
| });
|
| }
|
|
|
| function parseCasinoPageLine(line, index, nestedCasino) {
|
| const parts = line.split("|").map((part) => part.trim()).filter(Boolean);
|
| let pathPart = "";
|
| let titlePart = "";
|
|
|
| if (parts.length >= 2) {
|
| const leftLooksPath = looksLikePath(parts[0]);
|
| const rightLooksPath = looksLikePath(parts[1]);
|
| if (leftLooksPath || !rightLooksPath) {
|
| pathPart = parts[0];
|
| titlePart = parts.slice(1).join(" | ");
|
| } else {
|
| titlePart = parts[0];
|
| pathPart = parts[1];
|
| }
|
| } else if (looksLikePath(line)) {
|
| pathPart = line;
|
| titlePart = titleFromPath(line);
|
| } else {
|
| titlePart = line;
|
| }
|
|
|
| const title = titlePart || titleFromPath(pathPart) || "Casino";
|
| const path = normalizeCasinoPath(pathPart, title, index, nestedCasino);
|
| const slug = slugFromCasinoPath(path);
|
| return { title, slug, path };
|
| }
|
|
|
| function looksLikePath(value) {
|
| return /^\/?casino(?:\/|$)/i.test(value) || /^\//.test(value) || /\//.test(value);
|
| }
|
|
|
| function normalizeCasinoPath(pathPart, title, index, nestedCasino) {
|
| let pathValue = String(pathPart || "").trim(); |
| if (!pathValue) { |
| const slug = slugify(title.replace(/^casino\s+/i, "")); |
| return nestedCasino ? "/casino/" + slug + "/" : "/" + slug + "/"; |
| }
|
| pathValue = pathValue.replace(/^https?:\/\/[^/]+/i, "");
|
| if (!pathValue.startsWith("/")) pathValue = "/" + pathValue;
|
| if (!pathValue.endsWith("/")) pathValue += "/";
|
| return pathValue.replace(/\/+/g, "/");
|
| }
|
|
|
| function slugFromCasinoPath(pathValue) {
|
| const clean = String(pathValue || "/casino/").replace(/^\/+|\/+$/g, "");
|
| const last = clean.split("/").filter(Boolean).pop() || "casino";
|
| return slugify(last) || "casino";
|
| }
|
|
|
| function normalizeConfig(input) { |
| const crawlDomain = input.crawlDomain === true || input.crawlDomain === "true" || input.crawlDomain === "on"; |
| const urls = String(input.urls || "") |
| .split(/\r?\n|,/)
|
| .map((value) => value.trim())
|
| .filter(Boolean);
|
|
|
| const casinoPages = parseCasinoPages(input.casinoPages, input.nestedCasino !== false); |
| const menuItems = parseMenuItems(input.staticMenuItems); |
| const textReplacements = parseTextReplacements(input.textReplacements); |
| let sourceDomain = cleanDomain(input.sourceDomain); |
| if (!sourceDomain) { |
| try { |
| const archived = parseWaybackUrl(urls[0]); |
| const original = archived ? archived.original : normalizeOriginalUrl(urls[0]); |
| sourceDomain = cleanDomain(new URL(original).hostname); |
| } catch {} |
| } |
| const targetDomain = cleanDomain(input.targetDomain) || sourceDomain; |
| if (!urls.length && crawlDomain && sourceDomain) urls.push(`https://${sourceDomain}/`); |
| if (!urls.length) throw new Error("Add at least one URL, or enter a source domain and enable automatic domain discovery."); |
| const maxPages = Math.min(500, Math.max(1, Number.parseInt(input.maxPages, 10) || 100)); |
|
|
| return { |
| sourceDomain, |
| targetDomain, |
| snapshotMode: input.snapshotMode || "latest",
|
| snapshotDate: input.snapshotDate || "",
|
| snapshotTimestamp: input.snapshotTimestamp || "",
|
| themeName: input.themeName || "Recovered Casino Theme",
|
| casinoMenuLabel: input.casinoMenuLabel || "Casino",
|
| casinoRootPath: "/casino/",
|
| menuItems, |
| textReplacements, |
| crawlDomain, |
| maxPages, |
| nestedCasino: input.nestedCasino !== false,
|
| urls,
|
| casinoPages
|
| };
|
| }
|
|
|
| async function discoverArchiveUrls(config, logs) { |
| const domain = cleanDomain(config.sourceDomain); |
| if (!domain) throw new Error("Source domain is required for automatic discovery."); |
|
|
| const archivedInput = config.urls.map(parseWaybackUrl).find(Boolean); |
| const cutoff = archivedInput?.timestamp || |
| (/^\d{8,14}$/.test(config.snapshotTimestamp) ? config.snapshotTimestamp : "") || |
| (config.snapshotDate ? config.snapshotDate.replaceAll("-", "") + "235959" : ""); |
| const to = cutoff ? `&to=${cutoff}` : ""; |
| config._discoveryCutoff = cutoff; |
| const queryLimit = Math.min(2000, Math.max(500, config.maxPages * 4)); |
| const cdx = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(domain + "/*")}&output=json&fl=timestamp,original,statuscode,mimetype&filter=statuscode:200&filter=mimetype:text/html&collapse=urlkey&sort=reverse&limit=${queryLimit}${to}`; |
| logs.push(`Discovering archived HTML pages for ${domain} (limit ${config.maxPages})...`); |
|
|
| let rows; |
| try { |
| rows = await fetchJson(cdx); |
| } catch (error) { |
| logs.push(`Domain discovery failed; explicit URLs will still be used (${error.message}).`); |
| return config.urls; |
| } |
|
|
| const wantedHost = domain.replace(/^www\./i, "").toLowerCase(); |
| const preferredPrefixes = config.urls.map((candidate) => { |
| try { |
| const archived = parseWaybackUrl(candidate); |
| const parsed = new URL(archived?.original || normalizeOriginalUrl(candidate)); |
| const first = parsed.pathname.split("/").filter(Boolean)[0]; |
| return first ? `/${first.toLowerCase()}/` : ""; |
| } catch { return ""; } |
| }).filter(Boolean); |
| const discovered = []; |
| for (const row of Array.isArray(rows) ? rows.slice(1) : []) { |
| const [timestamp, original] = row; |
| if (!timestamp || !original) continue; |
| let parsed; |
| try { parsed = new URL(original); } catch { continue; } |
| if (parsed.hostname.replace(/^www\./i, "").toLowerCase() !== wantedHost) continue; |
| if (/\.(?:css|js|json|xml|png|jpe?g|gif|webp|svg|ico|pdf|zip|mp4|webm|woff2?|ttf)$/i.test(parsed.pathname)) continue; |
| if (/(?:\/wp-admin\/|\/wp-login\.php|\/feed\/?$|\/xmlrpc\.php)/i.test(parsed.pathname)) continue; |
| parsed.search = ""; |
| parsed.hash = ""; |
| discovered.push({ url: parsed.href, timestamp, path: normalizeRequestPath(parsed.pathname).toLowerCase() }); |
| } |
| discovered.sort((left, right) => { |
| const leftPreferred = preferredPrefixes.some((prefix) => left.path.startsWith(prefix)) ? 1 : 0; |
| const rightPreferred = preferredPrefixes.some((prefix) => right.path.startsWith(prefix)) ? 1 : 0; |
| return rightPreferred - leftPreferred || String(right.timestamp).localeCompare(String(left.timestamp)) || left.path.localeCompare(right.path); |
| }); |
|
|
| const merged = []; |
| const seen = new Set(); |
| for (const candidate of [...config.urls, ...discovered.map((item) => item.url)]) { |
| try { |
| const archived = parseWaybackUrl(candidate); |
| const original = archived?.original || normalizeOriginalUrl(candidate); |
| const parsed = new URL(original); |
| const key = parsed.hostname.replace(/^www\./i, "").toLowerCase() + normalizeRequestPath(parsed.pathname).toLowerCase(); |
| if (seen.has(key)) continue; |
| seen.add(key); |
| merged.push(candidate); |
| if (merged.length >= config.maxPages) break; |
| } catch {} |
| } |
| logs.push(`WebArchive returned ${discovered.length} usable HTML capture(s); ${merged.length} unique page(s) selected.`); |
| return merged.length ? merged : config.urls; |
| } |
|
|
| async function resolveSnapshot(rawUrl, config) { |
| const parsed = parseWaybackUrl(rawUrl);
|
| if (parsed) return parsed;
|
|
|
| const original = normalizeOriginalUrl(rawUrl);
|
| if (config.snapshotMode === "timestamp" && /^\d{8,14}$/.test(config.snapshotTimestamp)) {
|
| return { original, timestamp: config.snapshotTimestamp };
|
| }
|
|
|
| const toTimestamp = config._discoveryCutoff || |
| (config.snapshotMode === "date" && config.snapshotDate ? `${config.snapshotDate.replaceAll("-", "")}235959` : ""); |
| const to = toTimestamp ? `&to=${toTimestamp}` : ""; |
| const cdx = `https://web.archive.org/cdx?url=${encodeURIComponent(original)}&output=json&fl=timestamp,original,statuscode,mimetype&filter=statuscode:200&filter=mimetype:text/html&collapse=digest&sort=reverse&limit=1${to}`;
|
|
|
| try {
|
| const rows = await fetchJson(cdx);
|
| if (Array.isArray(rows) && rows.length > 1) {
|
| const parsedOriginal = parseWaybackUrl(rows[1][1]);
|
| return { timestamp: rows[1][0], original: parsedOriginal?.original || normalizeOriginalUrl(rows[1][1]) };
|
| }
|
| } catch {
|
|
|
| }
|
|
|
| const available = await fetchJson(`https://archive.org/wayback/available?url=${encodeURIComponent(original)}`);
|
| const closest = available?.archived_snapshots?.closest;
|
| if (!closest?.timestamp) throw new Error(`Не найден снимок WebArchive для ${original}`);
|
| const parsedClosest = parseWaybackUrl(closest.url);
|
| const closestOriginal = parsedClosest?.original || closest.url.replace(/^https?:\/\/web\.archive\.org\/web\/\d{8,14}(?:[a-z_]+)?\//i, "");
|
| return { timestamp: closest.timestamp, original: normalizeOriginalUrl(closestOriginal) };
|
| }
|
|
|
| function parseWaybackUrl(value) {
|
| const match = String(value).match(/web\.archive\.org\/web\/(\d{8,14})(?:[a-z_]+)?\/(https?:\/\/.+)$/i);
|
| if (!match) return null;
|
| return { timestamp: match[1], original: normalizeOriginalUrl(match[2]) };
|
| }
|
|
|
| async function rewriteHtmlAssets(html, pageUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| const attrPattern = /\b(src|href)=["']([^"']+)["']/gi;
|
| const replacements = [];
|
| let match;
|
|
|
| while ((match = attrPattern.exec(html))) {
|
| const [full, attr, raw] = match;
|
| if (!shouldDownloadAsset(raw, attr)) continue;
|
| const absolute = toAbsoluteAssetUrl(raw, pageUrl);
|
| if (!absolute) continue;
|
| replacements.push({ full, raw, absolute, attr });
|
| }
|
|
|
| const unique = [...new Map(replacements.map((item) => [item.absolute, item])).values()]; |
| logs.push(`Downloading ${unique.length} page asset(s) in small parallel batches...`); |
| const batchSize = 4; |
| for (let index = 0; index < unique.length; index += batchSize) { |
| const batch = unique.slice(index, index + batchSize); |
| const results = await Promise.allSettled(batch.map(async (item) => ({ |
| item, |
| local: await downloadAsset(item.absolute, timestamp, assetDir, assetMap, cssTexts, logs) |
| }))); |
| for (const [resultIndex, result] of results.entries()) { |
| if (result.status === "fulfilled") { |
| const { item, local } = result.value; |
| for (const occurrence of replacements.filter((candidate) => candidate.absolute === item.absolute)) { |
| html = html.replaceAll(occurrence.raw, `{{ACI_ASSET_URL}}/${local}`); |
| } |
| } else { |
| const failedItem = batch[resultIndex]; |
| logs.push(`Asset unavailable and removed from HTML: ${failedItem.absolute} (${result.reason?.message || "download failed"})`); |
| for (const occurrence of replacements.filter((candidate) => candidate.absolute === failedItem.absolute)) { |
| html = removeUnavailableAssetReference(html, occurrence); |
| } |
| } |
| } |
| logs.push(`Assets processed: ${Math.min(index + batch.length, unique.length)}/${unique.length} (${assetMap.size} stored).`); |
| } |
|
|
| return html;
|
| }
|
|
|
| async function downloadAsset(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| if (assetMap.has(assetUrl)) return assetMap.get(assetUrl).file; |
| const promiseKey = `${assetDir}|${timestamp}|${assetUrl}`; |
| if (assetDownloadPromises.has(promiseKey)) return assetDownloadPromises.get(promiseKey); |
| const promise = downloadAssetUncached(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs); |
| assetDownloadPromises.set(promiseKey, promise); |
| try { |
| return await promise; |
| } finally { |
| assetDownloadPromises.delete(promiseKey); |
| } |
| } |
|
|
| async function downloadAssetUncached(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| if (assetMap.has(assetUrl)) return assetMap.get(assetUrl).file; |
| if (assetMap.size >= MAX_ASSETS) { |
| throw new Error(`asset limit ${MAX_ASSETS} reached`);
|
| }
|
|
|
| const parsed = new URL(assetUrl);
|
| const ext = guessExtension(parsed.pathname);
|
| const file = `${hash(assetUrl)}${ext}`;
|
| const outPath = path.join(assetDir, file);
|
| const archivedUrl = `https://web.archive.org/web/${timestamp}id_/${assetUrl}`;
|
| let response; |
| let lastError; |
| for (let attempt = 1; attempt <= 3; attempt++) { |
| try { |
| response = await fetchWithTimeout(archivedUrl, ASSET_TIMEOUT_MS); |
| if (response.ok || ![408, 425, 429, 500, 502, 503, 504].includes(response.status)) break; |
| lastError = new Error(`HTTP ${response.status}`); |
| } catch (error) { |
| lastError = error; |
| } |
| if (attempt < 3) { |
| logs.push(`Retrying asset (${attempt}/2): ${assetUrl}`); |
| await delay(750 * attempt); |
| } |
| } |
| if (!response) throw lastError || new Error("fetch failed"); |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| let buffer = Buffer.from(await response.arrayBuffer());
|
|
|
| if (ext === ".css") {
|
| let css = buffer.toString("utf8");
|
| css = await rewriteCssAssets(css, assetUrl, timestamp, assetDir, assetMap, cssTexts, logs);
|
| cssTexts.push(css);
|
| buffer = Buffer.from(css, "utf8");
|
| }
|
|
|
| await fs.writeFile(outPath, buffer);
|
| assetMap.set(assetUrl, { file, bytes: buffer.length });
|
| return file; |
| } |
|
|
| async function tryDownloadReservationPlan(pageUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| const page = new URL(pageUrl); |
| const origins = [...new Set([ |
| page.origin, |
| `${page.protocol}//${page.hostname.replace(/^www\./i, "")}`, |
| `${page.protocol}//www.${page.hostname.replace(/^www\./i, "")}` |
| ])]; |
| const timestamps = [...new Set([timestamp, "20190717025956"])]; |
|
|
| for (const candidateTimestamp of timestamps) { |
| for (const origin of origins) { |
| const assetUrl = new URL("/img/Stoly1.jpg", origin).href; |
| try { |
| const file = await downloadAsset(assetUrl, candidateTimestamp, assetDir, assetMap, cssTexts, logs); |
| logs.push(`Reservation plan restored: ${assetUrl}`); |
| return file; |
| } catch { |
| |
| } |
| } |
| } |
|
|
| logs.push("Reservation plan image is absent from Web Archive; restored reservation details without the missing image."); |
| return ""; |
| } |
|
|
| async function rewriteCssAssets(css, cssUrl, timestamp, assetDir, assetMap, cssTexts, logs) {
|
| const matches = [...css.matchAll(/url\((?!['"]?data:)(?!['"]?#)['"]?([^'")]+)['"]?\)/gi)];
|
| for (const match of matches) {
|
| const raw = match[1].trim();
|
| const absolute = toAbsoluteAssetUrl(raw, cssUrl);
|
| if (!absolute) continue;
|
| try {
|
| const file = await downloadAsset(absolute, timestamp, assetDir, assetMap, cssTexts, logs);
|
| css = css.replaceAll(raw, file);
|
| } catch (error) {
|
| logs.push(`CSS asset skipped: ${absolute} (${error.message})`);
|
| }
|
| }
|
| return css;
|
| }
|
|
|
| function cleanWaybackHtml(html) {
|
| return html
|
| .replace(/<!--\s*BEGIN WAYBACK TOOLBAR INSERT[\s\S]*?END WAYBACK TOOLBAR INSERT\s*-->/gi, "")
|
| .replace(/<script\b[^>]*(?:webarchive|archive_analytics|wombat|wayback|_static\/js)[^>]*>[\s\S]*?<\/script>/gi, "")
|
| .replace(/<link\b[^>]*(?:webarchive|_static)[^>]*>/gi, "")
|
| .replace(/<div[^>]+id=["']wm-ipp["'][\s\S]*?<\/div>\s*<\/div>\s*<\/div>/gi, "")
|
| .replace(/<div[^>]+id=["']wm-ipp["'][\s\S]*?<\/div>/gi, "")
|
| .replace(/\s*__wm\.[^<]+/gi, "");
|
| }
|
|
|
| function patchCasinoMenu(html, label, href) {
|
| if (new RegExp(`href=["'][^"']*${escapeRegex(href)}[^"']*["']`, "i").test(html)) return html;
|
| const link = `<li class="aci-casino-menu-item"><a href="${href}">${escapeHtml(label)}</a></li>`;
|
| const menuPattern = /(<(?:div|nav)[^>]+class=["'][^"']*(?:menu|nav|navigation)[^"']*["'][^>]*>[\s\S]*?<ul[^>]*>[\s\S]*?)(<\/ul>)/i;
|
| if (menuPattern.test(html)) {
|
| return html.replace(menuPattern, `$1${link}$2`);
|
| }
|
| const navPattern = /(<nav\b[\s\S]*?)(<\/nav>)/i;
|
| if (navPattern.test(html)) {
|
| return html.replace(navPattern, (whole, navBody, close) => {
|
| if (/<\/ul>/i.test(navBody)) return `${navBody.replace(/<\/ul>/i, `${link}</ul>`)}${close}`;
|
| return `${navBody}<a class="aci-casino-menu-link" href="${href}">${escapeHtml(label)}</a>${close}`;
|
| });
|
| }
|
| return html;
|
| }
|
|
|
| function chunkString(value, size = 7000) {
|
| const chunks = [];
|
| for (let index = 0; index < value.length; index += size) chunks.push(value.slice(index, index + size));
|
| return chunks;
|
| }
|
|
|
| function phpString(value) {
|
| return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
| }
|
|
|
| function phpChunkArray(base64) {
|
| return `[${chunkString(base64).map(phpString).join(",")}]`;
|
| }
|
|
|
| async function listFilesRecursive(dir) {
|
| try {
|
| const entries = await fs.readdir(dir, { withFileTypes: true });
|
| const files = [];
|
| for (const entry of entries) {
|
| const full = path.join(dir, entry.name);
|
| if (entry.isDirectory()) files.push(...await listFilesRecursive(full));
|
| else files.push(full);
|
| }
|
| return files;
|
| } catch {
|
| return [];
|
| }
|
| }
|
|
|
| function mimeForAssetFile(file, data) {
|
| const ext = path.extname(file).toLowerCase();
|
| const fallback = mimeTypes[ext] || "application/octet-stream";
|
| if (fallback === "application/octet-stream") {
|
| const sample = data.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
|
| if (sample.startsWith("<!doctype html") || sample.startsWith("<html")) return "text/html; charset=utf-8";
|
| }
|
| return fallback;
|
| }
|
|
|
| function fixedProjectMenuHtml(config) { |
| const staticItems = (config.menuItems || []).map((item) => '<li><a href="' + escapeHtml(item.path) + '">' + escapeHtml(item.title) + '</a></li>').join("");
|
| const casinoChildren = (config.casinoPages || []).map((page) => '<li><a href="' + escapeHtml(page.path) + '">' + escapeHtml(page.title) + '</a></li>').join("");
|
| return staticItems + '<li class="aci-casino-accordion"><button class="aci-casino-toggle" type="button" aria-expanded="false">' + escapeHtml(config.casinoMenuLabel || "Casino") + '</button><ul class="aci-casino-submenu">' + casinoChildren + '</ul></li>';
|
| }
|
|
|
| function injectStaticCasinoAccordion(html) { |
| const css = '<style id="aci-casino-menu-style">.aci-casino-accordion{list-style:none}.menu>ul>li>.aci-casino-toggle{display:block;box-sizing:content-box;width:204px;min-height:0;margin:0 auto;padding:10px 0;border:0;border-bottom:1px solid #d2bb87;background:transparent;color:#99042f;font:inherit;line-height:inherit;text-align:left;cursor:pointer;-webkit-appearance:none;appearance:none}.aci-casino-toggle:after{content:" +";float:right}.aci-casino-accordion.is-open>.aci-casino-toggle:after{content:" -"}.aci-casino-submenu{display:none;width:100%;margin:0;padding:0!important}.aci-casino-accordion.is-open>.aci-casino-submenu{display:block}</style>'; |
| const js = '<script id="aci-casino-menu-script">document.addEventListener("click",function(event){var toggle=event.target.closest(".aci-casino-toggle");if(!toggle)return;event.preventDefault();var item=toggle.closest(".aci-casino-accordion");var open=!item.classList.contains("is-open");item.classList.toggle("is-open",open);toggle.setAttribute("aria-expanded",open?"true":"false");});</script>'; |
| if (html.includes('aci-casino-menu-style')) html = html.replace(/<style\b[^>]*id=["']aci-casino-menu-style["'][^>]*>[\s\S]*?<\/style>/i, css); |
| else html = html.replace(/<\/head>/i, css + '</head>'); |
| if (html.includes('aci-casino-menu-script')) html = html.replace(/<script\b[^>]*id=["']aci-casino-menu-script["'][^>]*>[\s\S]*?<\/script>/i, js); |
| else html = html.replace(/<\/body>/i, js + '</body>'); |
| return html; |
| } |
| |
| function removeUnavailableAssetReference(html, item) { |
| const raw = escapeRegex(item.raw); |
| if (item.attr.toLowerCase() === "src") { |
| html = html.replace(new RegExp(`<img\\b[^>]*\\bsrc=(["'])${raw}\\1[^>]*>`, "gi"), ""); |
| html = html.replace(new RegExp(`<script\\b[^>]*\\bsrc=(["'])${raw}\\1[^>]*>[\\s\\S]*?<\\/script\\s*>`, "gi"), ""); |
| return html.replace(new RegExp(`\\bsrc=(["'])${raw}\\1`, "gi"), ""); |
| } |
| html = html.replace(new RegExp(`<link\\b[^>]*\\bhref=(["'])${raw}\\1[^>]*>`, "gi"), ""); |
| return html.replace(new RegExp(`\\bhref=(["'])${raw}\\1`, "gi"), 'href="#"'); |
| } |
|
|
| function pruneStaticMenus(html, config) { |
| const menu = fixedProjectMenuHtml(config); |
| const replaced = replaceEditableContentRegion(html, "menu", `<ul>${menu}</ul>`); |
| if (replaced.ok) html = replaced.html; |
| return injectStaticCasinoAccordion(html); |
| } |
| |
| function removeCommercialArtifacts(html, config) { |
| const allowedHosts = new Set([config.sourceDomain, config.targetDomain] |
| .filter(Boolean) |
| .map((host) => String(host).replace(/^https?:\/\//i, "").replace(/^www\./i, "").split("/")[0].toLowerCase())); |
| html = html |
| .replace(/<meta\b[^>]*name=["']author["'][^>]*(?:jirout|reklamn[ií]\s+agentura)[^>]*\/?\s*>/gi, "") |
| .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, (script) => |
| /(?:google-analytics\.com|googletagmanager\.com|GoogleAnalyticsObject|\b_gaq\b)/i.test(script) ? "" : script |
| ) |
| .replace(/<li\b[^>]*>\s*<p\b[^>]*class=["'][^"']*nadpis[^"']*["'][^>]*>\s*Facebook\s*<\/p>[\s\S]*?<\/li>/gi, "") |
| .replace(/<p\b[^>]*>[\s\S]*?<\/p>/gi, (paragraph) => |
| /(?:jirout|reklamn[ií]\s+agentura)/i.test(paragraph) ? "" : paragraph |
| ) |
| .replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "") |
| .replace(/<iframe\b[^>]*\/?\s*>/gi, "") |
| .replace(/href=["'][^"']*mailto:\s*([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})[^"']*["']/gi, 'href="mailto:$1"'); |
| |
| return html.replace(/<a\b([^>]*)\bhref=["'](https?:\/\/[^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (whole, pre, href, post, inner) => { |
| try { |
| const host = new URL(href).hostname.replace(/^www\./i, "").toLowerCase(); |
| if ([...allowedHosts].some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) return whole; |
| } catch {} |
| return inner; |
| }); |
| } |
| |
| function injectReservationDetails(html, config) { |
| if (!/(?:\?rezervace|Pl[aá]nek rozm[ií]stěn[ií] stolů)/i.test(html)) return html; |
| |
| html = html.replace(/href=["'](?:[^"']*\?)?rezervace(?:#[^"']*)?["']/gi, 'href="#rezervace-plan"'); |
| if (html.includes('id="rezervace-plan"')) return html; |
| |
| const image = config._reservationAssetFile |
| ? `<img src="{{ACI_ASSET_URL}}/${escapeHtml(config._reservationAssetFile)}" alt="Rozmístění stolů v restauraci">` |
| : ""; |
| const css = '<style id="aci-reservation-style">.aci-reservation-plan{display:none;position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.72);padding:24px;overflow:auto}.aci-reservation-plan:target{display:flex;align-items:flex-start;justify-content:center}.aci-reservation-dialog{position:relative;width:min(760px,100%);margin:auto;background:#fff;color:#222;padding:28px;box-sizing:border-box}.aci-reservation-close{position:absolute;right:14px;top:8px;font-size:28px;line-height:1;text-decoration:none}.aci-reservation-dialog img{display:block;max-width:100%;height:auto;margin:18px auto 0}.fotky_menu img{display:inline-block;max-width:100%;object-fit:cover}</style>'; |
| const section = `<section id="rezervace-plan" class="aci-reservation-plan" aria-label="Rezervace"><div class="aci-reservation-dialog"><a class="aci-reservation-close" href="#" aria-label="Zavřít">×</a><h2>Rozmístění stolů v restauraci</h2><p>Pro rezervování stolu prosím použijte telefon <a href="tel:+420606045035">606 045 035</a>.</p>${image}</div></section>`; |
| if (!html.includes('aci-reservation-style')) html = html.replace(/<\/head>/i, css + '</head>'); |
| return html.replace(/<\/body>/i, section + '</body>'); |
| } |
| |
| function normalizeStaticHtmlForTheme(html, config) { |
| html = html.replace(/<nav class=["']aci-recovered-nav["']>[\s\S]*?<\/nav>\s*/gi, ""); |
| html = removeCommercialArtifacts(html, config); |
| html = pruneStaticMenus(html, config); |
| html = injectReservationDetails(html, config); |
| return html; |
| } |
|
|
| async function buildEmbeddedThemeData(themeDir, manifest, config) {
|
| const dataDir = path.join(themeDir, "data");
|
| const staticDir = path.join(dataDir, "static-html");
|
| const assetDir = path.join(dataDir, "archive-assets");
|
| const staticFiles = await listFilesRecursive(staticDir);
|
| const assetFiles = await listFilesRecursive(assetDir);
|
| const staticCases = [];
|
| const assetCases = [];
|
|
|
| for (const file of staticFiles) {
|
| const name = path.basename(file);
|
| const html = normalizeStaticHtmlForTheme(await fs.readFile(file, "utf8"), config);
|
| staticCases.push(` case ${phpString(name)}: return acwpb_embedded_decode(${phpChunkArray(Buffer.from(html, "utf8").toString("base64"))});`);
|
| }
|
|
|
| // Gutenberg-контент для каждой страницы (для post_content при импорте)
|
| const gutenbergCases = [];
|
| for (const page of manifest.staticPages) {
|
| if (page.gutenbergContent) {
|
| gutenbergCases.push(` case ${phpString(page.file)}: return acwpb_embedded_decode(${phpChunkArray(Buffer.from(page.gutenbergContent, "utf8").toString("base64"))});`);
|
| }
|
| }
|
|
|
| for (const file of assetFiles) {
|
| const name = path.basename(file);
|
| const data = await fs.readFile(file);
|
| const mime = mimeForAssetFile(name, data);
|
| assetCases.push(` case ${phpString(name)}: return ['mime' => ${phpString(mime)}, 'body' => acwpb_embedded_decode(${phpChunkArray(data.toString("base64"))})];`);
|
| }
|
|
|
| return {
|
| manifestChunks: phpChunkArray(Buffer.from(JSON.stringify(manifest, null, 2), "utf8").toString("base64")),
|
| staticCases: staticCases.join("\n"),
|
| gutenbergCases: gutenbergCases.join("\n"),
|
| assetCases: assetCases.join("\n")
|
| };
|
| }
|
| async function writePlugin(pluginDir, manifest) {
|
| const php = `<?php
|
| |
| |
| |
| |
|
|
|
|
| if (!defined('ABSPATH')) { exit; }
|
|
|
| define('ACI_PLUGIN_FILE', __FILE__);
|
| define('ACI_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
| define('ACI_PLUGIN_URL', plugin_dir_url(__FILE__));
|
|
|
| function aci_manifest() {
|
| static $manifest = null;
|
| if ($manifest === null) {
|
| $file = ACI_PLUGIN_DIR . 'data/pages.json';
|
| if (!file_exists($file)) {
|
| $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]];
|
| return $manifest;
|
| }
|
| $json = file_get_contents($file);
|
| $manifest = json_decode($json, true);
|
| if (!is_array($manifest)) {
|
| $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]];
|
| }
|
| if (!isset($manifest['staticPages']) || !is_array($manifest['staticPages'])) {
|
| $manifest['staticPages'] = [];
|
| }
|
| if (!isset($manifest['casino']) || !is_array($manifest['casino'])) {
|
| $manifest['casino'] = ['menuLabel' => 'Casino', 'pages' => []];
|
| }
|
| if (!isset($manifest['casino']['pages']) || !is_array($manifest['casino']['pages'])) {
|
| $manifest['casino']['pages'] = [];
|
| }
|
| if (empty($manifest['casino']['menuLabel'])) {
|
| $manifest['casino']['menuLabel'] = 'Casino';
|
| }
|
| }
|
| return $manifest;
|
| }
|
|
|
| function aci_static_html($file) {
|
| $path = ACI_PLUGIN_DIR . 'data/static-html/' . basename($file);
|
| if (!file_exists($path)) { return ''; }
|
| $html = file_get_contents($path);
|
| $html = str_replace('{{ACI_ASSET_URL}}', esc_url(ACI_PLUGIN_URL . 'data/archive-assets'), $html);
|
| $html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html);
|
| return $html;
|
| }
|
|
|
| function aci_template_redirect() {
|
| if (is_admin()) { return; }
|
| $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
| $path = '/' . trim($path, '/') . '/';
|
| if ($path === '//') { $path = '/'; }
|
| $manifest = aci_manifest();
|
| foreach ($manifest['staticPages'] as $page) {
|
| if ($page['path'] === $path) {
|
| status_header(200);
|
| echo aci_static_html($page['file']);
|
| exit;
|
| }
|
| }
|
| }
|
| add_action('template_redirect', 'aci_template_redirect', 0);
|
|
|
| function aci_admin_menu() {
|
| add_management_page('Archive Casino Importer', 'Archive Casino Importer', 'manage_options', 'archive-casino-importer', 'aci_admin_page');
|
| }
|
| add_action('admin_menu', 'aci_admin_menu');
|
|
|
| function aci_admin_page() {
|
| if (!current_user_can('manage_options')) { return; }
|
| $message = '';
|
| if (isset($_POST['aci_import']) && check_admin_referer('aci_import')) {
|
| $message = aci_run_import();
|
| }
|
| $manifest = aci_manifest();
|
| echo '<div class="wrap"><h1>Archive Casino Importer</h1>';
|
| if ($message) { echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>'; }
|
| if (!count($manifest['staticPages']) && !count($manifest['casino']['pages'])) {
|
| echo '<div class="notice notice-error"><p>Import manifest is empty or missing. Rebuild and reinstall the importer ZIP.</p></div>';
|
| }
|
| echo '<p>Static pages: ' . intval(count($manifest['staticPages'])) . '. Gutenberg Casino pages: ' . intval(count($manifest['casino']['pages'])) . '.</p>';
|
| echo '<form method="post">';
|
| wp_nonce_field('aci_import');
|
| submit_button('Import pages and menu', 'primary', 'aci_import');
|
| echo '</form></div>';
|
| }
|
|
|
| function aci_run_import() {
|
| $manifest = aci_manifest();
|
| if (!count($manifest['staticPages']) && !count($manifest['casino']['pages'])) {
|
| return 'Import manifest is empty. Nothing was imported.';
|
| }
|
| $menu_id = wp_create_nav_menu('Main Menu');
|
| if (is_wp_error($menu_id)) {
|
| $menu = wp_get_nav_menu_object('Main Menu');
|
| $menu_id = $menu ? $menu->term_id : 0;
|
| }
|
|
|
| foreach ($manifest['staticPages'] as $page) {
|
| $post_id = aci_upsert_page($page['title'], sanitize_title($page['title']), '<!-- Static archive page. Rendered by Archive Casino Importer. -->');
|
| update_post_meta($post_id, '_aci_static_file', $page['file']);
|
| if ($page['path'] === '/') {
|
| update_option('show_on_front', 'page');
|
| update_option('page_on_front', $post_id);
|
| }
|
| if ($menu_id) {
|
| wp_update_nav_menu_item($menu_id, 0, [
|
| 'menu-item-title' => $page['title'],
|
| 'menu-item-url' => home_url($page['path']),
|
| 'menu-item-status' => 'publish'
|
| ]);
|
| }
|
| }
|
|
|
| $casino_root_id = 0;
|
| foreach ($manifest['casino']['pages'] as $index => $page) {
|
| $content = aci_casino_page_content($page['title'], $index);
|
| $post_id = aci_upsert_page($page['title'], $page['slug'], $content);
|
| if ($index === 0) { $casino_root_id = $post_id; }
|
| if ($index > 0 && $casino_root_id && strpos($page['path'], '/casino/') === 0) {
|
| wp_update_post(['ID' => $post_id, 'post_parent' => $casino_root_id]);
|
| }
|
| }
|
|
|
| if ($menu_id) {
|
| wp_update_nav_menu_item($menu_id, 0, [
|
| 'menu-item-title' => $manifest['casino']['menuLabel'],
|
| 'menu-item-object' => 'page',
|
| 'menu-item-object-id' => $casino_root_id,
|
| 'menu-item-type' => 'post_type',
|
| 'menu-item-status' => 'publish'
|
| ]);
|
| $locations = get_theme_mod('nav_menu_locations', []);
|
| $locations['primary'] = $menu_id;
|
| set_theme_mod('nav_menu_locations', $locations);
|
| }
|
|
|
| flush_rewrite_rules();
|
| return 'Import complete.';
|
| }
|
|
|
| function aci_upsert_page($title, $slug, $content) {
|
| $existing = get_page_by_path($slug);
|
| $post = [
|
| 'post_title' => $title,
|
| 'post_name' => $slug,
|
| 'post_content' => $content,
|
| 'post_status' => 'publish',
|
| 'post_type' => 'page'
|
| ];
|
| if ($existing) {
|
| $post['ID'] = $existing->ID;
|
| wp_update_post($post);
|
| return $existing->ID;
|
| }
|
| return wp_insert_post($post);
|
| }
|
|
|
| function aci_casino_page_content($title, $index) {
|
| $title = esc_html($title);
|
| return '<!-- wp:group {"className":"casino-hero","layout":{"type":"constrained"}} --><div class="wp-block-group casino-hero"><!-- wp:heading {"level":1} --><h1>' . $title . '</h1><!-- /wp:heading --><!-- wp:paragraph --><p>Editable casino content section. Replace this starter copy with your article, review, bonuses, images, and editorial notes.</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Primary CTA</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group --><!-- wp:columns {"className":"casino-layout"} --><div class="wp-block-columns casino-layout"><!-- wp:column --><div class="wp-block-column"><!-- wp:heading {"level":2} --><h2>Overview</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Add article text, screenshots, comparison notes, and contextual links here.</p><!-- /wp:paragraph --><!-- wp:image --><figure class="wp-block-image"><img alt=""/></figure><!-- /wp:image --></div><!-- /wp:column --><!-- wp:column --><div class="wp-block-column"><!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Card</h3><!-- /wp:heading --><!-- wp:list --><ul><li>Bonus details</li><li>Payment notes</li><li>Editorial checks</li></ul><!-- /wp:list --></div><!-- /wp:group --></div><!-- /wp:column --></div><!-- /wp:columns --><!-- wp:heading {"level":2} --><h2>FAQ</h2><!-- /wp:heading --><!-- wp:details --><details class="wp-block-details"><summary>Question</summary><p>Answer text.</p></details><!-- /wp:details -->';
|
| }
|
|
|
| function aci_register_patterns() {
|
| if (!function_exists('register_block_pattern')) { return; }
|
| if (function_exists('register_block_pattern_category')) {
|
| register_block_pattern_category('archive-casino', ['label' => 'Archive Casino']);
|
| }
|
| register_block_pattern('archive-casino/review-card', [
|
| 'title' => 'Casino Review Card',
|
| 'categories' => ['archive-casino'],
|
| 'content' => '<!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Name</h3><!-- /wp:heading --><!-- wp:paragraph --><p>Short editable review summary.</p><!-- /wp:paragraph --><!-- wp:list --><ul><li>Pros item</li><li>Payment note</li><li>Bonus condition</li></ul><!-- /wp:list --></div><!-- /wp:group -->'
|
| ]);
|
| }
|
| add_action('init', 'aci_register_patterns');
|
| `;
|
|
|
| await fs.writeFile(path.join(pluginDir, "archive-casino-importer.php"), php, "utf8");
|
| }
|
|
|
| async function writeTheme(themeDir, config, manifest) { |
| const staticDir = path.join(themeDir, "data", "static-html"); |
| const archiveAssetDir = path.join(themeDir, "data", "archive-assets"); |
| for (const page of manifest.staticPages) { |
| if (!page.file) throw new Error(`Static page ${page.path || page.title || "unknown"} has no HTML file.`); |
| const staticFile = path.join(staticDir, path.basename(page.file)); |
| try { |
| const html = await fs.readFile(staticFile, "utf8"); |
| let normalizedHtml = normalizeStaticHtmlForTheme(html, config); |
| const marked = ensureEditableContentMarkers(normalizedHtml, page.contentSelector); |
| if (!marked.ok) throw new Error(marked.error); |
| normalizedHtml = marked.html; |
| const markedFooter = ensureEditableFooterMarkers(normalizedHtml); |
| if (!markedFooter.ok) throw new Error(markedFooter.error); |
| normalizedHtml = markedFooter.html; |
| if (!manifest.footer?.html) { |
| manifest.footer = { html: markedFooter.content }; |
| } |
| validateFullStaticDocument(normalizedHtml, page.path || page.file); |
| validateNoUnresolvedAssetReferences(normalizedHtml, page.path || page.file); |
| await validateStaticAssetReferences(normalizedHtml, archiveAssetDir, page.path || page.file); |
| page.embeddedHtmlBase64 = Buffer.from(normalizedHtml, "utf8").toString("base64"); |
| await fs.writeFile(staticFile, normalizedHtml, "utf8"); |
| } catch (error) { |
| throw new Error(`Cannot package static page ${page.path || page.file}: ${error.message}`); |
| } |
| } |
| |
| // Сохранить gutenberg-контент в отдельные файлы (НЕ embedded base64) |
| const gutDir = path.join(themeDir, "data", "gutenberg");
|
| await fs.mkdir(gutDir, { recursive: true });
|
| for (const page of manifest.staticPages) { |
| if (page.gutenbergContent && page.file) { |
| await fs.writeFile(path.join(gutDir, page.file), page.gutenbergContent, "utf8"); |
| } |
| } |
| await fs.writeFile(path.join(themeDir, "data", "pages.json"), JSON.stringify(manifest, null, 2), "utf8"); |
| |
| const designAssets = manifest.designAssets || {}; |
| const logoAsset = path.basename(designAssets.logo || ""); |
| const heroAsset = path.basename(designAssets.hero || ""); |
| const sidebarHeaderAsset = path.basename(designAssets.sidebarHeader || ""); |
| const effectivePrimary = /^#f{3,6}$/i.test(manifest.design.primary || "") |
| ? (manifest.design.accent || "#990033") |
| : manifest.design.primary; |
| |
| const style = ` |
| |
| |
| |
| |
|
|
|
|
| :root {
|
| --aci-primary: ${manifest.design.primary};
|
| --aci-accent: ${manifest.design.accent};
|
| --aci-bg: ${manifest.design.background};
|
| --aci-text: ${manifest.design.text};
|
| --aci-font: ${manifest.design.font};
|
| }
|
|
|
| body { |
| margin: 0; |
| background-color: var(--aci-bg); |
| color: var(--aci-text);
|
| font-family: var(--aci-font);
|
| line-height: 1.6;
|
| }
|
|
|
| a { color: var(--aci-primary); }
|
| .site-header, .site-footer {
|
| border-bottom: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent);
|
| padding: 18px max(24px, calc((100vw - 1120px) / 2));
|
| }
|
| .site-footer { border-top: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent); border-bottom: 0; margin-top: 48px; }
|
| .site-brand { font-weight: 700; color: var(--aci-text); text-decoration: none; }
|
| .site-nav ul { display: flex; flex-wrap: wrap; gap: 18px; list-style: none; margin: 14px 0 0; padding: 0; }
|
| .site-nav a { text-decoration: none; font-weight: 600; }
|
|
|
|
|
| .site-nav li { position: relative; }
|
| .site-nav .sub-menu {
|
| display: none;
|
| position: absolute;
|
| top: 100%;
|
| left: 0;
|
| flex-direction: column;
|
| gap: 6px;
|
| background: var(--aci-bg);
|
| border: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent);
|
| border-radius: 6px;
|
| padding: 8px 0;
|
| margin: 4px 0 0;
|
| min-width: 200px;
|
| z-index: 100;
|
| box-shadow: 0 6px 18px rgba(0,0,0,0.12);
|
| }
|
| .site-nav li:hover > .sub-menu,
|
| .site-nav li:focus-within > .sub-menu,
|
| .site-nav li.menu-item-has-children.is-open > .sub-menu {
|
| display: flex;
|
| }
|
| .site-nav .sub-menu li { white-space: nowrap; }
|
| .site-nav .sub-menu a { display: block; padding: 6px 16px; font-weight: 500; }
|
| .site-nav .sub-menu a:hover { background: color-mix(in srgb, var(--aci-primary) 8%, transparent); }
|
| .site-nav .menu-item-has-children > a::after { content: " ▾"; font-size: 0.8em; opacity: 0.7; } |
| .site-main { max-width: 1120px; margin: 0 auto; padding: 36px 24px; } |
|
|
| @media (max-width: 760px) { |
| .site-nav ul { align-items: flex-start; flex-direction: column; gap: 8px; } |
| .site-nav .sub-menu { position: static; box-shadow: none; margin: 4px 0 0 12px; } |
| } |
|
|
|
|
| .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; } |
|
|
| |
| .aci-casino-shell { min-height: 100vh; } |
| .aci-casino-header { min-height: 112px; } |
| .aci-casino-header .logo img { display: block; width: 249px; height: 92px; } |
| .aci-casino-banner { clear: both; width: 971px; height: 270px; margin: 0 auto; overflow: hidden; } |
| .aci-casino-banner img { display: block; width: 971px; height: 270px; object-fit: cover; } |
| .aci-casino-nav .sub-menu { display: none; } |
| .aci-casino-nav .menu-item-has-children.is-open > .sub-menu { display: block; } |
| .aci-casino-nav .menu-item-has-children > a::after { content: " +"; float: right; } |
| .aci-casino-nav .menu-item-has-children.is-open > a::after { content: " -"; } |
| .aci-casino-content { box-sizing: border-box; min-height: 520px; } |
| .aci-casino-content article.page { max-width: none; margin: 0; padding: 10px 12px 36px; } |
| .aci-casino-content h1, .aci-casino-content h2 { color: ${effectivePrimary}; } |
| .aci-casino-footer { box-sizing: border-box; margin: 0; color: #94042d; } |
| .aci-casino-footer a { color: #94042d; } |
|
|
| @media (max-width: 1000px) { |
| .aci-casino-shell .main { width: 100%; max-width: 994px; } |
| .aci-casino-banner { width: calc(100% - 24px); height: auto; aspect-ratio: 971 / 270; } |
| .aci-casino-banner img { width: 100%; height: 100%; } |
| .aci-casino-shell .insert_page { box-sizing: border-box; width: 100%; padding: 0 20px; background-color: #fff6e3; } |
| .aci-casino-shell .insert_page_left { width: 246px; } |
| .aci-casino-shell .insert_page_right { width: calc(100% - 266px); } |
| } |
|
|
| @media (max-width: 720px) { |
| .aci-casino-header { min-height: 104px; } |
| .aci-casino-header .logo { float: none; padding-left: 12px; } |
| .aci-casino-header .lang { display: none; } |
| .aci-casino-banner { display: none; } |
| .aci-casino-shell .insert_page { padding: 0 12px; } |
| .aci-casino-shell .insert_page_left, |
| .aci-casino-shell .insert_page_right { float: none; clear: both; width: 100%; } |
| .aci-casino-shell .insert_page_left > img { display: none; } |
| .aci-casino-shell .menu { width: 100%; box-sizing: border-box; } |
| .aci-casino-shell .menu ul li a:link, |
| .aci-casino-shell .menu ul li a:hover, |
| .aci-casino-shell .menu ul li a:visited { box-sizing: border-box; width: calc(100% - 24px); } |
| .aci-casino-footer { height: auto; min-height: 149px; padding: 24px 28px; } |
| .aci-casino-footer .texty, |
| .aci-casino-footer .copy { float: none; clear: both; width: auto; text-align: left; } |
| } |
| `; |
|
|
| // Демо-контент Casino (на русском, в стиле сайта) — вставляется в PHP-шаблон.
|
| // Экранируем обратные слеши и $ чтобы PHP получил литеральную строку.
|
| const casinoContentPhp = casinoDemoContent("__TITLE__").replace(/\$/g, "\\\\$").replace(/__TITLE__/g, "' . $title . '"); |
| const embeddedManifestBase64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); |
|
|
| const functions = `<?php
|
| |
| |
| |
|
|
| if (!defined('ABSPATH')) { exit; }
|
|
|
| function acwpb_theme_manifest() { |
| static $manifest = null; |
| if ($manifest !== null) { return $manifest; } |
| $file = get_stylesheet_directory() . '/data/pages.json'; |
| if (is_readable($file)) { |
| $json = file_get_contents($file); |
| $manifest = json_decode($json, true); |
| } |
| if (!is_array($manifest)) { |
| $embedded_json = base64_decode('${embeddedManifestBase64}', true); |
| $manifest = $embedded_json !== false ? json_decode($embedded_json, true) : null; |
| } |
| if (!is_array($manifest)) { $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []], 'menuItems' => []]; } |
| return $manifest; |
| } |
|
|
| function acwpb_theme_manifest_debug() { |
| $file = get_stylesheet_directory() . '/data/pages.json'; |
| $debug = ['path' => $file, 'exists' => file_exists($file), 'readable' => is_readable($file), 'bytes' => 0, 'json_error' => '', 'source' => 'embedded']; |
| if ($debug['readable']) { |
| $json = file_get_contents($file); |
| $debug['bytes'] = strlen((string) $json); |
| $decoded = json_decode($json, true); |
| $debug['json_error'] = json_last_error_msg(); |
| if (is_array($decoded)) { $debug['source'] = 'data/pages.json'; } |
| } |
| return $debug; |
| } |
|
|
| function acwpb_theme_setup() {
|
| add_theme_support('title-tag');
|
| add_theme_support('post-thumbnails');
|
| add_theme_support('wp-block-styles');
|
| add_theme_support('editor-styles');
|
| add_theme_support('responsive-embeds');
|
| register_nav_menus(['primary' => 'Primary Menu']);
|
| add_editor_style('style.css');
|
| }
|
| add_action('after_setup_theme', 'acwpb_theme_setup');
|
|
|
| function acwpb_theme_assets() { |
| $manifest = acwpb_theme_manifest(); |
| $assets = $manifest['designAssets'] ?? []; |
| $base = acwpb_theme_asset_base_url(); |
| $deps = []; |
| if (!empty($assets['cssReset'])) { |
| wp_enqueue_style('acwpb-archive-reset', $base . '/' . basename($assets['cssReset']), [], '1.2.0'); |
| $deps[] = 'acwpb-archive-reset'; |
| } |
| if (!empty($assets['cssMaster'])) { |
| wp_enqueue_style('acwpb-archive-master', $base . '/' . basename($assets['cssMaster']), $deps, '1.2.0'); |
| $deps[] = 'acwpb-archive-master'; |
| } |
| wp_enqueue_style('acwpb-theme-style', get_stylesheet_uri(), $deps, '1.2.0'); |
| } |
| add_action('wp_enqueue_scripts', 'acwpb_theme_assets'); |
| add_action('enqueue_block_editor_assets', 'acwpb_theme_assets'); |
|
|
| function acwpb_theme_sync_assets() { |
| $uploads = wp_upload_dir(); |
| if (!empty($uploads['error']) || empty($uploads['basedir'])) { return false; } |
| $source = get_stylesheet_directory() . '/data/archive-assets'; |
| $target = rtrim($uploads['basedir'], '/') . '/acwpb-assets'; |
| if (!is_dir($source)) { return false; } |
| if (!is_dir($target) && !wp_mkdir_p($target)) { return false; } |
| $files = glob($source . '/*'); |
| if (!is_array($files)) { return false; } |
| foreach ($files as $source_file) { |
| if (!is_file($source_file)) { continue; } |
| $target_file = $target . '/' . basename($source_file); |
| if (!is_file($target_file) || filesize($target_file) !== filesize($source_file)) { |
| copy($source_file, $target_file); |
| } |
| } |
| return true; |
| } |
|
|
| function acwpb_theme_asset_base_url() { |
| $uploads = wp_upload_dir(); |
| if (empty($uploads['error']) && !empty($uploads['baseurl'])) { |
| $target = rtrim($uploads['basedir'], '/') . '/acwpb-assets'; |
| if (!is_dir($target)) { acwpb_theme_sync_assets(); } |
| return rtrim($uploads['baseurl'], '/') . '/acwpb-assets'; |
| } |
| return home_url('/acwpb-assets'); |
| } |
|
|
| function acwpb_theme_render_static_content($page_id, $html) { |
| $start = '<!-- ACWPB_CONTENT_START -->'; |
| $end = '<!-- ACWPB_CONTENT_END -->'; |
| if (strpos($html, $start) === false || strpos($html, $end) === false) { return $html; } |
| $post = get_post($page_id); |
| if (!$post || $post->post_type !== 'page') { return $html; } |
| $rendered = apply_filters('the_content', (string) $post->post_content); |
| $pattern = '#<!-- ACWPB_CONTENT_START -->[\\s\\S]*?<!-- ACWPB_CONTENT_END -->#'; |
| return preg_replace_callback($pattern, function () use ($start, $end, $rendered) { |
| return $start . "\n" . $rendered . "\n" . $end; |
| }, $html, 1); |
| } |
|
|
| function acwpb_theme_default_footer_html() { |
| $manifest = acwpb_theme_manifest(); |
| return (string) ($manifest['footer']['html'] ?? '<p>Footer content</p>'); |
| } |
|
|
| function acwpb_theme_initialize_footer() { |
| $missing = '__ACWPB_FOOTER_MISSING__'; |
| if (get_option('acwpb_theme_footer_html', $missing) === $missing) { |
| update_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html()); |
| } |
| } |
|
|
| function acwpb_theme_footer_html() { |
| acwpb_theme_initialize_footer(); |
| $html = (string) get_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html()); |
| $html = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $html); |
| $html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html); |
| return $html; |
| } |
|
|
| function acwpb_theme_render_static_footer($html) { |
| $start = '<!-- ACWPB_FOOTER_START -->'; |
| $end = '<!-- ACWPB_FOOTER_END -->'; |
| if (strpos($html, $start) === false || strpos($html, $end) === false) { return $html; } |
| $footer = acwpb_theme_footer_html(); |
| $pattern = '#<!-- ACWPB_FOOTER_START -->[\\s\\S]*?<!-- ACWPB_FOOTER_END -->#'; |
| return preg_replace_callback($pattern, function () use ($start, $end, $footer) { |
| return $start . "\n" . $footer . "\n" . $end; |
| }, $html, 1); |
| } |
|
|
| function acwpb_theme_asset_router() { |
| if (is_admin()) { return; }
|
| $raw_path = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '/';
|
| if (preg_match('#^/acwpb-assets/(.+)$#', $raw_path, $match)) {
|
| $file = basename($match[1]);
|
| $path = get_stylesheet_directory() . '/data/archive-assets/' . $file; |
| if (!file_exists($path)) { status_header(404); return; }
|
| $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
| $mimes = ['css'=>'text/css','js'=>'application/javascript','png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg','gif'=>'image/gif','ico'=>'image/x-icon','svg'=>'image/svg+xml','html'=>'text/html; charset=utf-8','woff'=>'font/woff','woff2'=>'font/woff2','ttf'=>'font/ttf'];
|
| $mime = $mimes[$ext] ?? 'application/octet-stream';
|
| if ($ext === 'bin') {
|
| $sample = file_get_contents($path, false, null, 0, 20);
|
| if (stripos(trim($sample), '<!doctype') === 0 || stripos(trim($sample), '<html') === 0) { $mime = 'text/html; charset=utf-8'; }
|
| }
|
| status_header(200);
|
| header('Content-Type: ' . $mime);
|
| if (strpos($mime, 'text/html') === false) { header('Content-Disposition: inline; filename="' . $file . '"'); }
|
| else { header('Content-Disposition: inline'); }
|
| header('Content-Length: ' . filesize($path));
|
| readfile($path);
|
| exit;
|
| } |
| if (trim($raw_path, '/') === '') { |
| $manifest = acwpb_theme_manifest(); |
| $front_path = $manifest['staticPages'][0]['path'] ?? '/'; |
| if ($front_path !== '/') { wp_redirect(home_url($front_path), 301); } |
| else { return; } |
| exit; |
| } |
|
|
| if (is_page()) { |
| $page_id = get_queried_object_id(); |
| $static_file = $page_id ? get_post_meta($page_id, '_acwpb_static_file', true) : ''; |
| if ($static_file) { |
| $static_path = get_stylesheet_directory() . '/data/static-html/' . basename($static_file); |
| $html = is_readable($static_path) ? file_get_contents($static_path) : false; |
| if ($html === false) { |
| foreach ((acwpb_theme_manifest()['staticPages'] ?? []) as $page) { |
| if (($page['file'] ?? '') === $static_file && !empty($page['embeddedHtmlBase64'])) { |
| $html = base64_decode($page['embeddedHtmlBase64'], true); |
| break; |
| } |
| } |
| } |
| if ($html !== false) { |
| $html = acwpb_theme_render_static_content($page_id, $html); |
| $html = acwpb_theme_render_static_footer($html); |
| $html = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $html); |
| $html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html); |
| status_header(200); |
| header('Content-Type: text/html; charset=' . get_bloginfo('charset')); |
| echo $html; |
| exit; |
| } |
| } |
| } |
| } |
| add_action('template_redirect', 'acwpb_theme_asset_router', 1);
|
|
|
| function acwpb_theme_activate() { |
| $manifest = acwpb_theme_manifest(); |
| acwpb_theme_sync_assets(); |
| acwpb_theme_initialize_footer(); |
| $import_hash = md5(json_encode($manifest) . '|theme-1.2.0'); |
| if (get_option('acwpb_theme_imported') === $import_hash && acwpb_theme_import_is_complete($manifest)) { |
| return get_option('acwpb_theme_import_report', []); |
| } |
|
|
| $report = [ |
| 'static_expected' => count($manifest['staticPages'] ?? []), |
| 'static_imported' => 0, |
| 'casino_expected' => count($manifest['casino']['pages'] ?? []), |
| 'casino_imported' => 0, |
| 'errors' => [], |
| ]; |
|
|
| if (!$report['static_expected']) { |
| $report['errors'][] = 'Import manifest contains no recovered static pages.'; |
| } |
|
|
| $menu_id = acwpb_theme_menu_id(); |
| acwpb_theme_clear_menu($menu_id); |
| $static_root_id = 0; |
|
|
| $casino_root_id = acwpb_theme_upsert_path_page( |
| 'Casino', |
| '/casino/', |
| '<!-- wp:heading {"level":1} --><h1>Casino</h1><!-- /wp:heading -->', |
| ['preserve_existing' => true] |
| ); |
| if (is_wp_error($casino_root_id)) { |
| $report['errors'][] = 'Casino parent failed: ' . $casino_root_id->get_error_message(); |
| $casino_root_id = 0; |
| } |
|
|
| foreach (($manifest['staticPages'] ?? []) as $page) { |
| if (empty($page['title']) || empty($page['path']) || empty($page['file'])) { continue; } |
| $gut_file = get_stylesheet_directory() . '/data/gutenberg/' . $page['file']; |
| $content = ''; |
| if (is_readable($gut_file)) { |
| $content = file_get_contents($gut_file); |
| $content = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $content); |
| } |
| if (empty($content) && !empty($page['gutenbergContent'])) { |
| $content = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $page['gutenbergContent']); |
| } |
| if (empty($content)) { |
| $html_file = get_stylesheet_directory() . '/data/static-html/' . $page['file']; |
| if (is_readable($html_file)) { |
| $raw = file_get_contents($html_file); |
| $content = '<!-- wp:html -->' . $raw . '<!-- /wp:html -->'; |
| } |
| } |
| if (empty($content) && !empty($page['embeddedHtmlBase64'])) { |
| $raw = base64_decode($page['embeddedHtmlBase64'], true); |
| if ($raw !== false) { $content = '<!-- wp:html -->' . $raw . '<!-- /wp:html -->'; } |
| } |
| $post_id = acwpb_theme_upsert_path_page($page['title'], $page['path'], $content, ['preserve_existing' => true]); |
| if (is_wp_error($post_id) || !$post_id) { |
| $report['errors'][] = 'Static page failed: ' . $page['path'] . ' - ' . (is_wp_error($post_id) ? $post_id->get_error_message() : 'unknown error'); |
| continue; |
| } |
| update_post_meta($post_id, '_acwpb_static_path', $page['path']); |
| update_post_meta($post_id, '_acwpb_static_file', $page['file']); |
| $report['static_imported']++; |
| if ($page['path'] === '/' || $page['path'] === '/cz/') { $static_root_id = $post_id; } |
| } |
|
|
| foreach (($manifest['casino']['pages'] ?? []) as $page) { |
| if (empty($page['title']) || empty($page['path'])) { continue; } |
| $casino_content = !empty($page['gutenbergContent']) ? (string) $page['gutenbergContent'] : acwpb_theme_casino_content($page['title']); |
| $casino_content = str_replace('{{ACI_ASSET_URL}}', esc_url(acwpb_theme_asset_base_url()), $casino_content); |
| $casino_content = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $casino_content); |
| $post_id = acwpb_theme_upsert_path_page( |
| $page['title'], |
| $page['path'], |
| $casino_content, |
| ['preserve_existing' => true] |
| ); |
| if (is_wp_error($post_id) || !$post_id) { |
| $report['errors'][] = 'Casino page failed: ' . $page['path'] . ' - ' . (is_wp_error($post_id) ? $post_id->get_error_message() : 'unknown error'); |
| continue; |
| } |
| update_post_meta($post_id, '_acwpb_casino_page', '1'); |
| $report['casino_imported']++; |
| } |
|
|
| foreach (acwpb_theme_primary_menu_items() as $item) { |
| acwpb_theme_add_menu_path($menu_id, $item['title'], $item['path']); |
| } |
|
|
| $casino_pages_menu = $manifest['casino']['pages'] ?? [];
|
| $casino_label_menu = $manifest['casino']['menuLabel'] ?? 'Casino';
|
| $parent_db = wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $casino_label_menu, 'menu-item-url' => '#', |
| 'menu-item-type' => 'custom', 'menu-item-status' => 'publish', |
| 'menu-item-classes' => 'aci-casino-menu-parent menu-item-has-children' |
| ]); |
| if (!is_wp_error($parent_db) && !empty($parent_db)) { |
| foreach ($casino_pages_menu as $cp) { |
| $child = acwpb_theme_page_from_path($cp['path'] ?? ''); |
| if ($child) { |
| wp_update_nav_menu_item($menu_id, 0, [
|
| 'menu-item-title' => $cp['title'] ?? 'Casino', 'menu-item-object' => 'page',
|
| 'menu-item-object-id' => $child->ID, 'menu-item-type' => 'post_type',
|
| 'menu-item-parent-id' => $parent_db, 'menu-item-status' => 'publish'
|
| ]);
|
| }
|
| }
|
| }
|
|
|
| $locations = get_theme_mod('nav_menu_locations', []);
|
| $locations['primary'] = $menu_id; |
| set_theme_mod('nav_menu_locations', $locations); |
| if ($static_root_id) { update_option('show_on_front', 'page'); update_option('page_on_front', $static_root_id); } |
| if (!$report['errors'] && acwpb_theme_import_is_complete($manifest)) { |
| update_option('acwpb_theme_imported', $import_hash); |
| } else { |
| delete_option('acwpb_theme_imported'); |
| } |
| update_option('acwpb_theme_import_report', $report); |
| flush_rewrite_rules(); |
| return $report; |
| } |
| add_action('after_switch_theme', 'acwpb_theme_activate'); |
|
|
| function acwpb_theme_maybe_import() { |
| if ((function_exists('wp_installing') && wp_installing()) || get_transient('acwpb_theme_import_lock')) { return; } |
| $manifest = acwpb_theme_manifest(); |
| $import_hash = md5(json_encode($manifest) . '|theme-1.2.0'); |
| if (get_option('acwpb_theme_imported') === $import_hash && acwpb_theme_import_is_complete($manifest)) { return; } |
| set_transient('acwpb_theme_import_lock', 1, 30); |
| acwpb_theme_activate(); |
| delete_transient('acwpb_theme_import_lock'); |
| } |
| add_action('init', 'acwpb_theme_maybe_import', 99); |
|
|
| function acwpb_theme_import_notice() { |
| if (!current_user_can('manage_options')) { return; } |
| $manifest = acwpb_theme_manifest(); |
| $report = get_option('acwpb_theme_import_report', []); |
| $expected_static = count($manifest['staticPages'] ?? []); |
| $expected_casino = count($manifest['casino']['pages'] ?? []); |
| $complete = acwpb_theme_import_is_complete($manifest); |
| $screen_link = admin_url('themes.php?page=acwpb-theme-import'); |
| if (!$complete) { |
| echo '<div class="notice notice-error"><p><strong>Recovered Site Import is incomplete.</strong> Expected ' . intval($expected_static) . ' static pages and ' . intval($expected_casino) . ' Casino pages. <a href="' . esc_url($screen_link) . '">Open import diagnostics</a>.</p></div>'; |
| } elseif (!empty($report)) { |
| echo '<div class="notice notice-success is-dismissible"><p>Recovered pages are installed: ' . intval($report['static_imported'] ?? $expected_static) . ' static and ' . intval($report['casino_imported'] ?? $expected_casino) . ' Casino pages.</p></div>'; |
| } |
| } |
| add_action('admin_notices', 'acwpb_theme_import_notice'); |
|
|
| function acwpb_theme_admin_menu() { |
| add_theme_page('Recovered Site Import', 'Recovered Site Import', 'manage_options', 'acwpb-theme-import', 'acwpb_theme_import_page'); |
| add_theme_page('Footer Content', 'Footer Content', 'manage_options', 'acwpb-footer-content', 'acwpb_theme_footer_page'); |
| } |
| add_action('admin_menu', 'acwpb_theme_admin_menu');
|
|
|
| function acwpb_theme_import_page() { |
| if (!current_user_can('manage_options')) { return; }
|
| $message = '';
|
| if (isset($_POST['acwpb_theme_import']) && check_admin_referer('acwpb_theme_import')) { |
| delete_option('acwpb_theme_imported'); |
| $report = acwpb_theme_activate(); |
| $message = empty($report['errors']) ? 'Import completed.' : 'Import finished with errors.'; |
| } |
| $manifest = acwpb_theme_manifest(); |
| $report = get_option('acwpb_theme_import_report', []); |
| $debug = acwpb_theme_manifest_debug(); |
| echo '<div class="wrap"><h1>Recovered Site Import</h1>'; |
| if ($message) { echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>'; } |
| echo '<p>Manifest: <strong>' . intval(count($manifest['staticPages'] ?? [])) . '</strong> static pages and <strong>' . intval(count($manifest['casino']['pages'] ?? [])) . '</strong> Casino pages.</p>'; |
| echo '<p>Manifest source: <code>' . esc_html($debug['source']) . '</code>. File exists: ' . ($debug['exists'] ? 'yes' : 'no') . '; readable: ' . ($debug['readable'] ? 'yes' : 'no') . '; bytes: ' . intval($debug['bytes']) . '; JSON: ' . esc_html($debug['json_error'] ?: 'not read') . '.</p>'; |
| if ($report) { |
| echo '<p>Last import: ' . intval($report['static_imported'] ?? 0) . ' static pages and ' . intval($report['casino_imported'] ?? 0) . ' Casino pages.</p>'; |
| foreach (($report['errors'] ?? []) as $error) { echo '<div class="notice notice-error inline"><p>' . esc_html($error) . '</p></div>'; } |
| } |
| echo '<form method="post">';
|
| wp_nonce_field('acwpb_theme_import');
|
| submit_button('Re-run import', 'primary', 'acwpb_theme_import');
|
| echo '</form></div>'; |
| } |
|
|
| function acwpb_theme_footer_page() { |
| if (!current_user_can('manage_options')) { return; } |
| $message = ''; |
| if (isset($_POST['acwpb_footer_save']) && check_admin_referer('acwpb_footer_save')) { |
| $footer = isset($_POST['acwpb_footer_html']) ? wp_unslash($_POST['acwpb_footer_html']) : ''; |
| update_option('acwpb_theme_footer_html', wp_kses_post($footer)); |
| $message = 'Footer saved. The change is active on recovered and Casino pages.'; |
| } elseif (isset($_POST['acwpb_footer_restore']) && check_admin_referer('acwpb_footer_restore')) { |
| update_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html()); |
| $message = 'Archived footer restored.'; |
| } |
| acwpb_theme_initialize_footer(); |
| $footer = (string) get_option('acwpb_theme_footer_html', acwpb_theme_default_footer_html()); |
| echo '<div class="wrap"><h1>Footer Content</h1>'; |
| if ($message) { echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($message) . '</p></div>'; } |
| echo '<form method="post">'; |
| wp_nonce_field('acwpb_footer_save'); |
| wp_editor($footer, 'acwpb_footer_html', ['textarea_name' => 'acwpb_footer_html', 'media_buttons' => true, 'textarea_rows' => 14]); |
| submit_button('Save footer', 'primary', 'acwpb_footer_save'); |
| echo '</form><hr><form method="post">'; |
| wp_nonce_field('acwpb_footer_restore'); |
| submit_button('Restore archived footer', 'secondary', 'acwpb_footer_restore', false); |
| echo '</form></div>'; |
| } |
|
|
| function acwpb_theme_slug_from_path($path) { |
| $slug = trim($path, '/');
|
| if ($slug === '') { return 'home'; }
|
| return str_replace('/', '-', $slug);
|
| } |
|
|
| function acwpb_theme_normalize_path($path) { |
| $path = parse_url((string) $path, PHP_URL_PATH); |
| $segments = array_values(array_filter(explode('/', trim((string) $path, '/')))); |
| $segments = array_map('sanitize_title', $segments); |
| return implode('/', array_filter($segments)); |
| } |
|
|
| function acwpb_theme_page_from_path($path) { |
| $normalized = acwpb_theme_normalize_path($path); |
| if ($normalized === '') { return null; } |
| return get_page_by_path($normalized, OBJECT, 'page'); |
| } |
|
|
| function acwpb_theme_upsert_path_page($title, $path, $content, $args = []) { |
| $normalized = acwpb_theme_normalize_path($path); |
| if ($normalized === '') { return new WP_Error('acwpb_empty_path', 'Page path is empty.'); } |
| $segments = explode('/', $normalized); |
| $parent_id = 0; |
| $current = []; |
|
|
| foreach ($segments as $index => $segment) { |
| $current[] = $segment; |
| $is_last = $index === count($segments) - 1; |
| $existing = get_page_by_path(implode('/', $current), OBJECT, 'page'); |
| if ($existing && !$is_last) { |
| $parent_id = (int) $existing->ID; |
| continue; |
| } |
|
|
| $page_title = $is_last ? $title : ucwords(str_replace(['-', '_'], ' ', $segment)); |
| $page_content = $is_last ? $content : ''; |
| $post = [ |
| 'post_title' => $page_title, |
| 'post_name' => $segment, |
| 'post_parent' => $parent_id, |
| 'post_status' => 'publish', |
| 'post_type' => 'page', |
| ]; |
| $preserve = !empty($args['preserve_existing']); |
| if (!$existing || !$preserve || trim((string) $existing->post_content) === '') { |
| $post['post_content'] = $page_content; |
| } |
| if ($existing) { |
| $post['ID'] = $existing->ID; |
| $page_id = wp_update_post($post, true); |
| } else { |
| $page_id = wp_insert_post($post, true); |
| } |
| if (is_wp_error($page_id) || !$page_id) { return $page_id; } |
| $parent_id = (int) $page_id; |
| } |
|
|
| return $parent_id; |
| } |
|
|
| function acwpb_theme_import_is_complete($manifest) { |
| $static_pages = $manifest['staticPages'] ?? []; |
| if (!count($static_pages)) { return false; } |
| foreach ($static_pages as $page) { |
| if (empty($page['path']) || !acwpb_theme_page_from_path($page['path'])) { return false; } |
| } |
| foreach (($manifest['casino']['pages'] ?? []) as $page) { |
| if (empty($page['path']) || !acwpb_theme_page_from_path($page['path'])) { return false; } |
| } |
| return acwpb_theme_menu_is_complete($manifest); |
| } |
|
|
| function acwpb_theme_menu_is_complete($manifest) { |
| $locations = get_theme_mod('nav_menu_locations', []); |
| $menu_id = isset($locations['primary']) ? (int) $locations['primary'] : 0; |
| if (!$menu_id) { return false; } |
| $items = wp_get_nav_menu_items($menu_id); |
| if (!is_array($items)) { return false; } |
| $static_items = $manifest['menuItems'] ?? []; |
| $casino_pages = $manifest['casino']['pages'] ?? []; |
| if (count($items) !== count($static_items) + count($casino_pages) + 1) { return false; } |
|
|
| $casino_label = $manifest['casino']['menuLabel'] ?? 'Casino'; |
| $casino_parent = null; |
| $top_titles = []; |
| foreach ($items as $item) { |
| $parent_id = (int) ($item->menu_item_parent ?? 0); |
| if ($parent_id === 0) { $top_titles[] = (string) ($item->title ?? ''); } |
| if ($parent_id === 0 && (string) ($item->title ?? '') === $casino_label) { $casino_parent = $item; } |
| } |
| if (!$casino_parent) { return false; } |
| foreach ($static_items as $expected) { |
| if (!in_array((string) ($expected['title'] ?? ''), $top_titles, true)) { return false; } |
| } |
| $child_titles = []; |
| foreach ($items as $item) { |
| if ((int) ($item->menu_item_parent ?? 0) === (int) $casino_parent->ID) { |
| $child_titles[] = (string) ($item->title ?? ''); |
| } |
| } |
| if (count($child_titles) !== count($casino_pages)) { return false; } |
| foreach ($casino_pages as $expected) { |
| if (!in_array((string) ($expected['title'] ?? ''), $child_titles, true)) { return false; } |
| } |
| return true; |
| } |
|
|
| function acwpb_theme_primary_menu_items() {
|
| $manifest = acwpb_theme_manifest();
|
| $items = [];
|
| foreach (($manifest['menuItems'] ?? []) as $mi) {
|
| if (!empty($mi['title']) && !empty($mi['path'])) { $items[] = ['title' => $mi['title'], 'path' => $mi['path']]; }
|
| }
|
| return $items;
|
| }
|
|
|
| function acwpb_theme_clear_menu($menu_id) {
|
| if (!$menu_id) { return; }
|
| $items = wp_get_nav_menu_items($menu_id);
|
| if (is_array($items)) { foreach ($items as $item) { if (!empty($item->ID)) { wp_delete_post($item->ID, true); } } }
|
| }
|
|
|
| function acwpb_theme_menu_id() {
|
| $menu = wp_get_nav_menu_object('Main Menu');
|
| if ($menu) { return $menu->term_id; }
|
| $created = wp_create_nav_menu('Main Menu');
|
| return is_wp_error($created) ? 0 : $created;
|
| }
|
|
|
| function acwpb_theme_add_menu_url($menu_id, $title, $url) { |
| if (!$menu_id) { return; }
|
| $items = wp_get_nav_menu_items($menu_id);
|
| if (is_array($items)) {
|
| foreach ($items as $item) {
|
| if (!empty($item->url) && untrailingslashit($item->url) === untrailingslashit($url)) { return; }
|
| }
|
| }
|
| wp_update_nav_menu_item($menu_id, 0, ['menu-item-title' => $title, 'menu-item-url' => $url, 'menu-item-status' => 'publish']); |
| } |
|
|
| function acwpb_theme_add_menu_path($menu_id, $title, $path) { |
| if (!$menu_id) { return; } |
| $page = acwpb_theme_page_from_path($path); |
| if ($page) { |
| wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $title, |
| 'menu-item-object' => 'page', |
| 'menu-item-object-id' => $page->ID, |
| 'menu-item-type' => 'post_type', |
| 'menu-item-status' => 'publish', |
| ]); |
| return; |
| } |
| acwpb_theme_add_menu_url($menu_id, $title, home_url($path)); |
| } |
|
|
| function acwpb_theme_upsert_page($title, $slug, $content) {
|
| $slug = sanitize_title($slug);
|
| $existing = get_page_by_path($slug, OBJECT, 'page');
|
| if (!$existing) {
|
| $found = get_posts(['post_type' => 'page', 'post_status' => 'any', 'name' => $slug, 'numberposts' => 1]);
|
| $existing = is_array($found) && count($found) ? $found[0] : null;
|
| }
|
| $post = ['post_title' => $title, 'post_name' => $slug, 'post_content' => $content, 'post_status' => 'publish', 'post_type' => 'page'];
|
| if ($existing) { $post['ID'] = $existing->ID; return wp_update_post($post, true); }
|
| return wp_insert_post($post, true);
|
| }
|
|
|
| function acwpb_theme_casino_content($title) {
|
| $title = esc_html($title);
|
| return '${casinoContentPhp}';
|
| }
|
|
|
| function acwpb_theme_register_patterns() {
|
| if (!function_exists('register_block_pattern')) { return; }
|
| if (function_exists('register_block_pattern_category')) {
|
| register_block_pattern_category('archive-casino', ['label' => 'Archive Casino']);
|
| }
|
| }
|
| add_action('init', 'acwpb_theme_register_patterns');
|
| `;
|
|
|
| const header = `<!doctype html>
|
| <html <?php language_attributes(); ?>>
|
| <head>
|
| <meta charset="<?php bloginfo('charset'); ?>">
|
| <meta name="viewport" content="width=device-width, initial-scale=1">
|
| <?php wp_head(); ?>
|
| </head>
|
| <body <?php body_class(); ?>> |
| <?php wp_body_open(); ?> |
| <?php $aci_asset_base = acwpb_theme_asset_base_url(); ?> |
| <div class="top aci-casino-shell"> |
| <div class="main"> |
| <header class="aci-casino-header"> |
| <div class="logo"> |
| <a href="<?php echo esc_url(home_url('/cz/')); ?>" aria-label="<?php echo esc_attr(get_bloginfo('name')); ?>"> |
| ${logoAsset ? `<img src="<?php echo esc_url($aci_asset_base . '/${logoAsset}'); ?>" alt="<?php echo esc_attr(get_bloginfo('name')); ?>">` : `<?php bloginfo('name'); ?>`} |
| </a> |
| </div> |
| <div class="lang"><ul><li><a class="select" href="<?php echo esc_url(home_url('/cz/')); ?>">cz</a></li></ul></div> |
| <div class="cleaner"></div> |
| </header> |
| ${heroAsset ? `<div class="aci-casino-banner"><img src="<?php echo esc_url($aci_asset_base . '/${heroAsset}'); ?>" alt=""></div>` : ``} |
| <div class="insert_page aci-casino-layout"> |
| <aside class="insert_page_left"> |
| ${sidebarHeaderAsset ? `<img src="<?php echo esc_url($aci_asset_base . '/${sidebarHeaderAsset}'); ?>" alt="Restaurace">` : ``} |
| <nav class="menu aci-casino-nav" aria-label="Primary navigation"> |
| <?php wp_nav_menu(['theme_location' => 'primary', 'container' => false, 'menu_class' => 'aci-casino-wp-menu', 'fallback_cb' => false]); ?> |
| </nav> |
| <div class="insert_left_page_footer"> </div> |
| </aside> |
| <main class="insert_page_right aci-casino-content"> |
| `; |
|
|
| const footer = ` </main> |
| <div class="cleaner"></div> |
| </div> |
| <footer class="paticka aci-casino-footer"> |
| <?php echo acwpb_theme_footer_html(); ?> |
| </footer> |
| </div> |
| </div> |
| <script> |
| document.addEventListener('DOMContentLoaded', function () { |
| document.querySelectorAll('.aci-casino-nav .menu-item-has-children > a[href="#"]').forEach(function (link) { |
| link.setAttribute('aria-expanded', 'false'); |
| link.setAttribute('role', 'button'); |
| }); |
| }); |
| document.addEventListener('click', function (event) { |
| var link = event.target.closest('.aci-casino-nav .menu-item-has-children > a[href="#"]'); |
| if (!link) { return; } |
| event.preventDefault(); |
| var item = link.closest('.menu-item-has-children'); |
| var willOpen = !item.classList.contains('is-open'); |
| document.querySelectorAll('.aci-casino-nav .menu-item-has-children.is-open').forEach(function (openItem) { |
| if (openItem !== item) { |
| openItem.classList.remove('is-open'); |
| var openLink = openItem.querySelector(':scope > a'); |
| if (openLink) { openLink.setAttribute('aria-expanded', 'false'); } |
| } |
| }); |
| item.classList.toggle('is-open', willOpen); |
| link.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); |
| }); |
| </script> |
| <?php wp_footer(); ?> |
| </body> |
| </html> |
| `; |
|
|
| const page = `<?php get_header(); ?> |
| <?php while (have_posts()) : the_post(); ?> |
| <article <?php post_class('entry-content'); ?>> |
| <?php the_content(); ?>
|
| </article>
|
| <?php endwhile; ?>
|
| <?php get_footer(); ?>
|
| `;
|
|
|
| const finalStyle = manifest.aiCustomCss |
| ? style + `\n/* AI Review approved customizations */\n${manifest.aiCustomCss}\n` |
| : style; |
| await fs.writeFile(path.join(themeDir, "style.css"), finalStyle, "utf8"); |
| await fs.writeFile(path.join(themeDir, "functions.php"), functions, "utf8");
|
| await fs.writeFile(path.join(themeDir, "header.php"), header, "utf8");
|
| await fs.writeFile(path.join(themeDir, "footer.php"), footer, "utf8");
|
| await fs.writeFile(path.join(themeDir, "page.php"), page, "utf8"); |
| await fs.writeFile(path.join(themeDir, "index.php"), page, "utf8"); |
| await fs.writeFile(path.join(themeDir, "README.txt"), [ |
| config.themeName, |
| "", |
| "1. Install the ZIP in Appearance > Themes > Add New > Upload Theme.", |
| "2. Activate the theme. Recovered static pages and editable Casino pages are created automatically.", |
| "3. Verify the import under Appearance > Recovered Site Import.", |
| "4. Edit page content under Pages. WordPress injects it into the protected recovered layout; Casino pages remain fully Gutenberg-editable.", |
| "5. Edit the global footer under Appearance > Footer Content. It is shared by recovered and Casino pages.", |
| "", |
| `Expected recovered pages: ${manifest.staticPages.length}`, |
| `Expected Casino pages: ${manifest.casino?.pages?.length || 0}`, |
| ].join("\n"), "utf8"); |
| } |
|
|
| // ---------------------------------------------------------------------------
|
| // Генерация демо-контента Casino-страницы (на русском, в стиле сайта).
|
| // Пользователь может удалить и заменить своими статьями.
|
| // ---------------------------------------------------------------------------
|
| function casinoDemoContent(title) {
|
| const t = escapeHtml(title);
|
| return '<!-- wp:group {"className":"casino-hero","layout":{"type":"constrained"}} -->' +
|
| '<div class="wp-block-group casino-hero">' +
|
| '<!-- wp:heading {"level":1} --><h1>' + t + '</h1><!-- /wp:heading -->' +
|
| '<!-- wp:paragraph --><p>Редактируемая страница Casino. Здесь размещается статья с обзором, рейтингом, бонусами и изображениями. Стиль адаптирован под дизайн сайта. Замените этот демо-контент своим.</p><!-- /wp:paragraph -->' +
|
| '<!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Играть →</a></div><!-- /wp:button --></div><!-- /wp:buttons -->' +
|
| '</div><!-- /wp:group -->' +
|
| '<!-- wp:group {"className":"casino-rating-card"} --><div class="wp-block-group casino-rating-card">' +
|
| '<!-- wp:heading {"level":2} --><h2>Рейтинг</h2><!-- /wp:heading -->' +
|
| '<!-- wp:paragraph {"className":"casino-stars"} --><p class="casino-stars">★★★★★</p><!-- /wp:paragraph -->' +
|
| '<!-- wp:columns {"className":"casino-pros-cons"} --><div class="wp-block-columns casino-pros-cons">' +
|
| '<!-- wp:column {"className":"casino-pros"} --><div class="wp-block-column casino-pros">' +
|
| '<!-- wp:heading {"level":3} --><h3>Плюсы</h3><!-- /wp:heading -->' +
|
| '<!-- wp:list --><ul><li>Лицензия Curaçao</li><li>Быстрый вывод</li><li>Мобильная версия</li></ul><!-- /wp:list -->' +
|
| '</div><!-- /wp:column -->' +
|
| '<!-- wp:column {"className":"casino-cons"} --><div class="wp-block-column casino-cons">' +
|
| '<!-- wp:heading {"level":3} --><h3>Минусы</h3><!-- /wp:heading -->' +
|
| '<!-- wp:list --><ul><li>Высокий вейджер</li><li>Нет поддержки на чешском</li></ul><!-- /wp:list -->' +
|
| '</div><!-- /wp:column --></div><!-- /wp:columns -->' +
|
| '</div><!-- /wp:group -->' +
|
| '<!-- wp:heading {"level":2} --><h2>Обзор</h2><!-- /wp:heading -->' +
|
| '<!-- wp:paragraph --><p>Описание казино: ассортимент игр, провайдеры, лицензия, методы оплаты и вывода. Текст редактируется в Gutenberg — кликни на блок, чтобы изменить.</p><!-- /wp:paragraph -->' +
|
| '<!-- wp:image --><figure class="wp-block-image"><img alt="Скриншот казино"/></figure><!-- /wp:image -->' +
|
| '<!-- wp:heading {"level":2} --><h2>Бонусы</h2><!-- /wp:heading -->' +
|
| '<!-- wp:table --><figure class="wp-block-table"><table><thead><tr><th>Бонус</th><th>Сумма</th><th>Вейджер</th></tr></thead>' +
|
| '<tbody><tr><td>Приветственный</td><td>100% до $500</td><td>x35</td></tr>' +
|
| '<tr><td>Без депозита</td><td>$20</td><td>x50</td></tr>' +
|
| '<tr><td>Фриспины</td><td>200 FS</td><td>x40</td></tr></tbody></table></figure><!-- /wp:table -->' +
|
| '<!-- wp:heading {"level":2} --><h2>FAQ</h2><!-- /wp:heading -->' +
|
| '<!-- wp:details --><details class="wp-block-details"><summary>Лицензировано ли казино?</summary><p>Да, лицензия Curaçao eGaming.</p></details><!-- /wp:details -->' +
|
| '<!-- wp:details --><details class="wp-block-details"><summary>Какие методы вывода доступны?</summary><p>Банковские карты, Neteller, Skrill, криптовалюты.</p></details><!-- /wp:details -->';
|
| }
|
|
|
| // ===========================================================================
|
| // ФАЗА ОЧИСТКИ: авто-удаление битых (404) и внешних ссылок
|
| // ===========================================================================
|
|
|
| /**
|
| * Очистка HTML от битых ссылок (404) и внешних ссылок на чужие домены.
|
| * - <a href="чужой_домен">текст</a> → снимается обёртка <a>, остаётся текст
|
| * - <img src="чужой_домен"> → удаляется полностью
|
| * - <iframe src="чужой_домен"> → удаляется полностью
|
| * - 404-е ссылки — то же самое
|
| * Возвращает { cleanedHtml, removed: [{type, url, reason, action}] }.
|
| */
|
| async function cleanExternalLinks(html, sourceDomain, progressLogs) {
|
| const removed = [];
|
| const sourceHost = sourceDomain.replace(/^www\./, "").toLowerCase();
|
|
|
| // Собрать все href/src с http(s)
|
| const urlPattern = /\b(href|src)=["'](https?:\/\/[^"']+)["']/gi;
|
| const matches = [...html.matchAll(urlPattern)];
|
| const toCheck = [];
|
| const seen = new Set();
|
|
|
| for (const m of matches) {
|
| const url = m[2];
|
| if (seen.has(url)) continue;
|
| seen.add(url);
|
| if (url.includes("web.archive.org")) continue;
|
| toCheck.push({ url, attr: m[1] });
|
| }
|
|
|
| // Проверить доступность и домен (батчами по 8)
|
| const broken = new Set();
|
| const external = new Set();
|
|
|
| if (progressLogs) progressLogs.push(`[Clean] проверяю ${toCheck.length} ссылок...`);
|
| const BATCH = 8;
|
| for (let i = 0; i < toCheck.length; i += BATCH) {
|
| const batch = toCheck.slice(i, i + BATCH);
|
| const results = await Promise.allSettled(batch.map(async (item) => {
|
| try {
|
| const host = new URL(item.url).hostname.replace(/^www\./, "").toLowerCase();
|
| // внешний домен?
|
| if (host !== sourceHost && !host.endsWith("." + sourceHost)) {
|
| return { ...item, status: 0, external: true };
|
| }
|
| // внутренний — проверяем HEAD
|
| const controller = new AbortController();
|
| const timer = setTimeout(() => controller.abort(), 6000);
|
| const r = await fetch(item.url, { method: "HEAD", signal: controller.signal, redirect: "follow" });
|
| clearTimeout(timer);
|
| return { ...item, status: r.status, external: false };
|
| } catch (e) {
|
| return { ...item, status: 0, external: true, unreachable: true };
|
| }
|
| }));
|
| for (const r of results) {
|
| if (r.status !== "fulfilled") continue;
|
| const val = r.value;
|
| if (val.external) {
|
| external.add(val.url);
|
| const host = safeHostname(val.url);
|
| removed.push({ url: val.url, reason: val.unreachable ? "недоступен" : "чужой домен", action: "удалён" });
|
| } else if (val.status === 404) {
|
| broken.add(val.url);
|
| removed.push({ url: val.url, reason: "404", action: "удалён" });
|
| } else if (val.status === 0) {
|
| external.add(val.url);
|
| removed.push({ url: val.url, reason: "timeout/недоступен", action: "удалён" });
|
| }
|
| }
|
| }
|
| const toRemove = new Set([...broken, ...external]);
|
|
|
| if (toRemove.size === 0) {
|
| if (progressLogs) progressLogs.push(`[Clean] битых и внешних ссылок не найдено`);
|
| return { cleanedHtml: html, removed: [] };
|
| }
|
|
|
| // Применить удаления через DOM-подобный разбор
|
| let cleaned = html;
|
|
|
| // 1. Удалить <iframe src="битый"> полностью
|
| cleaned = cleaned.replace(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*>[\s\S]*?<\/iframe>/gi, (whole, src) => {
|
| if (toRemove.has(src)) { return ""; }
|
| return whole;
|
| });
|
| // self-closing iframe
|
| cleaned = cleaned.replace(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*\/?>/gi, (whole, src) => {
|
| if (toRemove.has(src)) { return ""; }
|
| return whole;
|
| });
|
|
|
| // 2. Удалить <img src="битый"> полностью
|
| cleaned = cleaned.replace(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*\/?>/gi, (whole, src) => {
|
| if (toRemove.has(src)) { return ""; }
|
| return whole;
|
| });
|
|
|
| // 3. Для <a href="битый"> — снять обёртку, оставить содержимое (текст)
|
| cleaned = cleaned.replace(/<a\b([^>]*)\bhref=["']([^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (whole, pre, href, post, inner) => {
|
| if (toRemove.has(href)) { return inner; }
|
| return whole;
|
| });
|
|
|
| if (progressLogs) {
|
| progressLogs.push(`[Clean] удалено ссылок: ${toRemove.size} (битых: ${broken.size}, внешних: ${external.size})`);
|
| for (const r of removed.slice(0, 15)) {
|
| progressLogs.push(` - ${r.url.slice(0, 70)} (${r.reason})`);
|
| }
|
| }
|
|
|
| return { cleanedHtml: cleaned, removed };
|
| }
|
|
|
| function safeHostname(url) { |
| try { return new URL(url).hostname; } catch { return "?"; } |
| } |
|
|
| function unwrapGutenbergHtmlBlock(content) { |
| return String(content || "") |
| .replace(/^\s*<!--\s*wp:html\s*-->\s*/i, "") |
| .replace(/\s*<!--\s*\/wp:html\s*-->\s*$/i, "") |
| .trim(); |
| } |
|
|
| function validateEditableHtmlFragment(content) { |
| if (/<\/?(?:html|head|body)\b/i.test(content)) { |
| return "Only the page content area can be edited. Document-level html/head/body tags are protected."; |
| } |
| if (/<(?:link|meta|base)\b/i.test(content)) { |
| return "Stylesheets and document metadata are protected. Edit text and content markup only."; |
| } |
| if (/<script\b/i.test(content)) { |
| return "Scripts are protected and cannot be added in the content editor."; |
| } |
| return ""; |
| } |
|
|
| function editableSelectorCandidates(contentSelector) { |
| const configured = String(contentSelector || "") |
| .replace(/[.#]/g, " ") |
| .split(/\s+/) |
| .map((item) => item.trim()) |
| .filter(Boolean); |
| return [...new Set([ |
| ...configured, |
| "insert_page_right", |
| "entry-content", |
| "main-content", |
| "main_content", |
| "post-content", |
| "post_content", |
| "page-content", |
| "content-area", |
| "site-main", |
| "content" |
| ])]; |
| } |
|
|
| function locateEditableContentRegion(html, contentSelector) { |
| for (const cls of editableSelectorCandidates(contentSelector)) { |
| const openRe = new RegExp(`<((?:div|main|article|section))\\b[^>]*class=["'][^"']*\\b${escapeRegex(cls)}\\b[^"']*["'][^>]*>`, "i"); |
| const open = openRe.exec(html); |
| if (!open) continue; |
| const tag = open[1].toLowerCase(); |
| const innerStart = open.index + open[0].length; |
| const tagRe = new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi"); |
| tagRe.lastIndex = innerStart; |
| let depth = 1; |
| let token; |
| while ((token = tagRe.exec(html)) !== null) { |
| if (/^<\//.test(token[0])) depth--; |
| else if (!/\/>$/.test(token[0])) depth++; |
| if (depth === 0) { |
| return { innerStart, innerEnd: token.index, selector: cls, tag }; |
| } |
| } |
| } |
| return null; |
| } |
|
|
| function ensureEditableContentWrapper(html, contentSelector) { |
| const existing = locateEditableContentRegion(html, contentSelector); |
| if (existing) return { html, selector: existing.selector }; |
| const heading = /<h[12]\b[^>]*>/i.exec(html); |
| if (!heading) return { html, selector: contentSelector || "content" }; |
| const openRe = /<(main|article|section|div)\b[^>]*>/gi; |
| let best = null; |
| let open; |
| while ((open = openRe.exec(html)) !== null && open.index < heading.index) { |
| const region = locateBalancedTagContent(html, open); |
| if (region && region.innerEnd > heading.index && (!best || open.index > best.open.index)) best = { open, region }; |
| } |
| if (!best) return { html, selector: contentSelector || "content" }; |
| const selector = "acwpb-detected-content"; |
| const opening = best.open[0]; |
| const updatedOpening = /\bclass=(['"])/i.test(opening) |
| ? opening.replace(/\bclass=(['"])([^'"]*)\1/i, (_whole, quote, classes) => `class=${quote}${classes} ${selector}${quote}`) |
| : opening.replace(/>$/, ` class="${selector}">`); |
| return { |
| html: html.slice(0, best.open.index) + updatedOpening + html.slice(best.open.index + opening.length), |
| selector |
| }; |
| } |
|
|
| function replaceEditableContentRegion(fullHtml, contentSelector, newContent) { |
| const region = locateEditableContentRegion(fullHtml, contentSelector); |
| if (!region) { |
| return { ok: false, error: `Protected content wrapper not found (${contentSelector || "automatic selector"}). The full HTML file was not changed.` }; |
| } |
| return { |
| ok: true, |
| html: fullHtml.slice(0, region.innerStart) + "\n" + newContent.trim() + "\n" + fullHtml.slice(region.innerEnd), |
| selector: region.selector |
| }; |
| } |
|
|
| const STATIC_CONTENT_START = "<!-- ACWPB_CONTENT_START -->"; |
| const STATIC_CONTENT_END = "<!-- ACWPB_CONTENT_END -->"; |
| const STATIC_FOOTER_START = "<!-- ACWPB_FOOTER_START -->"; |
| const STATIC_FOOTER_END = "<!-- ACWPB_FOOTER_END -->"; |
|
|
| function ensureEditableContentMarkers(fullHtml, contentSelector) { |
| if (fullHtml.includes(STATIC_CONTENT_START) && fullHtml.includes(STATIC_CONTENT_END)) { |
| return { ok: true, html: fullHtml }; |
| } |
| const region = locateEditableContentRegion(fullHtml, contentSelector); |
| if (!region) { |
| return { ok: false, error: `Editable content wrapper not found (${contentSelector || "automatic selector"}).` }; |
| } |
| return { |
| ok: true, |
| html: fullHtml.slice(0, region.innerStart) + "\n" + STATIC_CONTENT_START + "\n" + |
| fullHtml.slice(region.innerStart, region.innerEnd).trim() + "\n" + STATIC_CONTENT_END + "\n" + |
| fullHtml.slice(region.innerEnd), |
| selector: region.selector |
| }; |
| } |
|
|
| function locateEditableFooterRegion(html) { |
| const candidates = ["paticka", "site-footer", "footer"]; |
| for (const cls of candidates) { |
| const openRe = new RegExp(`<((?:footer|div|section))\\b[^>]*class=["'][^"']*\\b${escapeRegex(cls)}\\b[^"']*["'][^>]*>`, "i"); |
| const open = openRe.exec(html); |
| if (!open) continue; |
| const region = locateBalancedTagContent(html, open); |
| if (region) return { ...region, selector: cls }; |
| } |
| const semanticFooter = /<footer\b[^>]*>/i.exec(html); |
| return semanticFooter ? locateBalancedTagContent(html, semanticFooter) : null; |
| } |
|
|
| function locateBalancedTagContent(html, open) { |
| const tagMatch = /^<([a-z0-9:-]+)/i.exec(open[0]); |
| if (!tagMatch) return null; |
| const tag = tagMatch[1].toLowerCase(); |
| const innerStart = open.index + open[0].length; |
| const tagRe = new RegExp(`<\\/?${escapeRegex(tag)}\\b[^>]*>`, "gi"); |
| tagRe.lastIndex = innerStart; |
| let depth = 1; |
| let token; |
| while ((token = tagRe.exec(html)) !== null) { |
| if (/^<\//.test(token[0])) depth--; |
| else if (!/\/>$/.test(token[0])) depth++; |
| if (depth === 0) return { innerStart, innerEnd: token.index, tag }; |
| } |
| return null; |
| } |
|
|
| function ensureEditableFooterMarkers(fullHtml) { |
| if (fullHtml.includes(STATIC_FOOTER_START) && fullHtml.includes(STATIC_FOOTER_END)) { |
| const extracted = extractMarkedRegion(fullHtml, STATIC_FOOTER_START, STATIC_FOOTER_END); |
| return { ok: true, html: fullHtml, content: extracted || "" }; |
| } |
| const region = locateEditableFooterRegion(fullHtml); |
| if (region) { |
| const content = fullHtml.slice(region.innerStart, region.innerEnd).trim(); |
| return { |
| ok: true, |
| html: fullHtml.slice(0, region.innerStart) + "\n" + STATIC_FOOTER_START + "\n" + content + "\n" + STATIC_FOOTER_END + "\n" + fullHtml.slice(region.innerEnd), |
| content |
| }; |
| } |
| const fallback = `<footer class="acwpb-editable-footer">\n${STATIC_FOOTER_START}\n<p>Footer content</p>\n${STATIC_FOOTER_END}\n</footer>`; |
| if (/<\/body>/i.test(fullHtml)) { |
| return { ok: true, html: fullHtml.replace(/<\/body>/i, fallback + "\n</body>"), content: "<p>Footer content</p>" }; |
| } |
| return { ok: false, error: "HTML document has no footer and no closing body tag." }; |
| } |
|
|
| function extractMarkedRegion(html, start, end) { |
| const startIndex = html.indexOf(start); |
| const endIndex = html.indexOf(end, startIndex + start.length); |
| if (startIndex < 0 || endIndex < 0) return null; |
| return html.slice(startIndex + start.length, endIndex).trim(); |
| } |
|
|
| function extractEditableFooterContent(fullHtml) { |
| const marked = ensureEditableFooterMarkers(fullHtml); |
| if (!marked.ok) return marked; |
| return { ok: true, content: extractMarkedRegion(marked.html, STATIC_FOOTER_START, STATIC_FOOTER_END) || "" }; |
| } |
|
|
| function replaceEditableFooterRegion(fullHtml, newContent) { |
| const marked = ensureEditableFooterMarkers(fullHtml); |
| if (!marked.ok) return marked; |
| const startIndex = marked.html.indexOf(STATIC_FOOTER_START); |
| const endIndex = marked.html.indexOf(STATIC_FOOTER_END, startIndex + STATIC_FOOTER_START.length); |
| return { |
| ok: true, |
| html: marked.html.slice(0, startIndex + STATIC_FOOTER_START.length) + "\n" + newContent.trim() + "\n" + marked.html.slice(endIndex) |
| }; |
| } |
|
|
| // =========================================================================== |
| // ФАЗА РАЗБОРКИ: splitPageHtml — разбор архивного HTML на части |
| // ===========================================================================
|
|
|
| /**
|
| * Разобрать полный архивный HTML на 4 части:
|
| * headAssets — <link>/<script> из <head> (для wp_enqueue)
|
| * shellHtml — шапка, слайдер, меню (до контентной зоны) → header.php
|
| * contentHtml — КОНТЕНТ страницы → Gutenberg-блоки
|
| * footerHtml — футер (после контентной зоны) → footer.php
|
| *
|
| * Эвристика поиска контентной зоны:
|
| * 1) классы: insert_page_right, content, main-content, article, entry-content
|
| * 2) элемент с наибольшим числом <p>/<h1>-<h6>/<img>
|
| */
|
| function splitPageHtml(html, contentSelector) {
|
| let bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
| const body = bodyMatch ? bodyMatch[1] : html;
|
|
|
| let headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
| const headAssets = headMatch ? extractHeadAssets(headMatch[1]) : { styles: [], scripts: [] };
|
|
|
| // поиск контентной зоны
|
| let contentStart = -1, contentEnd = -1, contentOpenTag = "", contentCloseCount = 0;
|
|
|
| const selectors = contentSelector
|
| ? [contentSelector]
|
| : ["insert_page_right", "content", "main-content", "main_content",
|
| "article", "entry-content", "post-content", "post_content",
|
| "page-content", "content-area", "site-main"];
|
|
|
| for (const cls of selectors) {
|
| const re = new RegExp(`<div[^>]*class=["'][^"']*\\b${cls}\\b[^"']*["'][^>]*>`, "i");
|
| const m = body.match(re);
|
| if (m) {
|
| contentStart = body.indexOf(m[0]);
|
| contentOpenTag = m[0];
|
| break;
|
| }
|
| }
|
|
|
| // если не найдено по классам — fallback: найти <h1> и взять от него
|
| if (contentStart === -1) {
|
| const h1 = body.match(/<h[12][^>]*>/i);
|
| if (h1) {
|
| contentStart = body.indexOf(h1[0]);
|
| contentOpenTag = "";
|
| }
|
| }
|
|
|
| // если совсем ничего — весь body
|
| if (contentStart === -1) {
|
| return {
|
| headAssets,
|
| shellHtml: "",
|
| contentHtml: body,
|
| footerHtml: "",
|
| contentSelector: "(full body)"
|
| };
|
| }
|
|
|
| // найти конец контентной зоны: баланс div-ов от contentStart
|
| if (contentOpenTag) {
|
| // найти закрывающий тег для открывающего contentOpenTag
|
| const afterStart = contentStart + contentOpenTag.length;
|
| let depth = 1;
|
| const tagRe = /<\/?div\b[^>]*>/gi;
|
| tagRe.lastIndex = afterStart;
|
| let m2;
|
| while ((m2 = tagRe.exec(body)) !== null) {
|
| if (m2[0].startsWith("</")) {
|
| depth--;
|
| if (depth === 0) {
|
| contentEnd = m2.index + m2[0].length;
|
| break;
|
| }
|
| } else {
|
| depth++;
|
| }
|
| }
|
| if (contentEnd === -1) contentEnd = body.length;
|
| const contentFull = body.slice(contentStart, contentEnd);
|
| // вытащить innerHTML (между открывающим и закрывающим тегом)
|
| const contentHtml = contentFull.slice(contentOpenTag.length, contentFull.length - 6); // -"</div>"
|
| return {
|
| headAssets,
|
| shellHtml: body.slice(0, contentStart),
|
| contentHtml,
|
| footerHtml: body.slice(contentEnd),
|
| contentSelector: contentOpenTag.match(/class=["']([^"']*)["']/i)?.[1] || "(detected)"
|
| };
|
| } else {
|
| // без обёртки (fallback на h1) — контент до конца body
|
| return {
|
| headAssets,
|
| shellHtml: body.slice(0, contentStart),
|
| contentHtml: body.slice(contentStart),
|
| footerHtml: "",
|
| contentSelector: "(h1 fallback)"
|
| };
|
| }
|
| }
|
|
|
| function extractHeadAssets(headHtml) {
|
| const styles = [];
|
| const scripts = [];
|
| for (const m of headHtml.matchAll(/<link[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*>/gi)) {
|
| styles.push(m[1]);
|
| }
|
| for (const m of headHtml.matchAll(/<script[^>]*\bsrc=["']([^"']+)["'][^>]*><\/script>/gi)) {
|
| scripts.push(m[1]);
|
| }
|
| return { styles, scripts };
|
| }
|
|
|
| // ===========================================================================
|
| // ФАЗА КОНВЕРТАЦИИ: htmlToGutenberg — HTML-фрагмент → Gutenberg-блоки
|
| // ===========================================================================
|
|
|
| /**
|
| * Конвертировать контентную зону в ОДИН Custom HTML блок.
|
| * Весь контент (заголовки, абзацы, таблицы, картинки) идёт внутри
|
| * <!-- wp:html -->...<!-- /wp:html --> — это не ломается при редактировании
|
| * в WordPress HTML-редакторе. wp_kses_post не вырезает содержимое wp:html.
|
| *
|
| * Очистка: декодирование сущностей, удаление устаревших атрибутов,
|
| * нормализация пробелов. Без разбиения на блоки — единый HTML-фрагмент.
|
| */
|
| function htmlToGutenberg(html) {
|
| // декодировать сущности и очистить
|
| html = sanitizeForGutenberg(html);
|
| if (!html.trim()) return "";
|
| // Один Custom HTML блок со всем контентом
|
| return `<!-- wp:html -->\n${html}\n<!-- /wp:html -->`;
|
| }
|
|
|
| /**
|
| * Разбить HTML на top-level элементы (теги + текстовые узлы).
|
| */
|
| function splitTopLevelElements(html) {
|
| const elements = [];
|
| let i = 0;
|
| const tagRe = /<(\w+)(\s[^>]*)?>([\s\S]*?)<\/\1>|<(\w+)(\s[^>]*)?\/?>/g;
|
| let lastEnd = 0;
|
| let m;
|
|
|
| while ((m = tagRe.exec(html)) !== null) {
|
| // текст перед тегом
|
| if (m.index > lastEnd) {
|
| const text = html.slice(lastEnd, m.index).trim();
|
| if (text && text !== " ") {
|
| 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 !== " ") {
|
| 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(/ |\s|<br\/?>/gi, "") === "") {
|
| return `<!-- wp:spacer {"height":"24px"} -->\n<div style="height:24px" aria-hidden="true" class="wp-block-spacer"></div>\n<!-- /wp:spacer -->`;
|
| }
|
| // разбить по двойному <br> на отдельные параграфы
|
| const parts = inner.split(/<br\s*\/?>\s*<br\s*\/?>/i);
|
| if (parts.length > 1) {
|
| return parts.map(p => {
|
| const t = sanitizeForGutenberg(p);
|
| if (!t) return null;
|
| const cls = align ? ` class="has-text-align-${align}"` : "";
|
| const atts = align ? ` {"align":"${align}"}` : "";
|
| return `<!-- wp:paragraph${atts} -->\n<p${cls}>${t}</p>\n<!-- /wp:paragraph -->`;
|
| }).filter(Boolean).join("\n\n");
|
| }
|
| // одиночный <br> внутри → разбиваем тоже (Gutenberg не любит <br> в параграфах)
|
| const brParts = inner.split(/<br\s*\/?>/i);
|
| if (brParts.length > 1) {
|
| return brParts.map(p => {
|
| const t = sanitizeForGutenberg(p);
|
| if (!t) return null;
|
| const cls = align ? ` class="has-text-align-${align}"` : "";
|
| const atts = align ? ` {"align":"${align}"}` : "";
|
| return `<!-- wp:paragraph${atts} -->\n<p${cls}>${t}</p>\n<!-- /wp:paragraph -->`;
|
| }).filter(Boolean).join("\n\n");
|
| }
|
| inner = sanitizeForGutenberg(inner);
|
| const cls = align ? ` class="has-text-align-${align}"` : "";
|
| const atts = align ? ` {"align":"${align}"}` : "";
|
| return `<!-- wp:paragraph${atts} -->\n<p${cls}>${inner}</p>\n<!-- /wp:paragraph -->`;
|
| }
|
|
|
| case "table": {
|
| // очистить внутренний HTML таблицы от устаревших атрибутов
|
| const cleanInner = sanitizeForGutenberg(el.inner);
|
| return `<!-- wp:table -->\n<figure class="wp-block-table"><table>${cleanInner}</table></figure>\n<!-- /wp:table -->`;
|
| }
|
|
|
| case "ul": {
|
| const cleanInner = sanitizeForGutenberg(el.inner);
|
| return `<!-- wp:list -->\n<ul>${cleanInner}</ul>\n<!-- /wp:list -->`;
|
| }
|
| case "ol": {
|
| const cleanInner = sanitizeForGutenberg(el.inner);
|
| return `<!-- wp:list {"ordered":true} -->\n<ol>${cleanInner}</ol>\n<!-- /wp:list -->`;
|
| }
|
|
|
| case "img": {
|
| const src = extractAttr(el.attrs, "src") || "";
|
| const alt = extractAttr(el.attrs, "alt") || "";
|
| const w = extractStyleValue(el.attrs, "width") || extractAttr(el.attrs, "width");
|
| const h = extractStyleValue(el.attrs, "height") || extractAttr(el.attrs, "height");
|
| // width/height через атрибуты (wp_kses_post их разрешает)
|
| const wAttr = w ? ` width="${parseInt(w)}"` : "";
|
| const hAttr = h ? ` height="${parseInt(h)}"` : "";
|
| return `<!-- wp:image -->\n<figure class="wp-block-image"><img src="${src}" alt="${alt}"${wAttr}${hAttr}/></figure>\n<!-- /wp:image -->`;
|
| }
|
|
|
| case "a": {
|
| // <a> с <img> внутри → image block с linkDestination
|
| const href = extractAttr(el.attrs, "href") || "";
|
| const imgMatch = el.inner.match(/<img\b[^>]*\/?>/i);
|
| if (imgMatch) {
|
| const imgAttrs = imgMatch[0];
|
| const src = extractAttr(imgAttrs, "src") || "";
|
| const alt = extractAttr(imgAttrs, "alt") || "";
|
| const w = extractStyleValue(imgAttrs, "width") || extractAttr(imgAttrs, "width");
|
| const h = extractStyleValue(imgAttrs, "height") || extractAttr(imgAttrs, "height");
|
| const wAttr = w ? ` width="${parseInt(w)}"` : "";
|
| const hAttr = h ? ` height="${parseInt(h)}"` : "";
|
| return `<!-- wp:image -->\n<figure class="wp-block-image"><a href="${href}"><img src="${src}" alt="${alt}"${wAttr}${hAttr}/></a></figure>\n<!-- /wp:image -->`;
|
| }
|
| // обычная ссылка → paragraph
|
| const text = sanitizeForGutenberg(el.inner);
|
| if (!text) return null;
|
| return `<!-- wp:paragraph -->\n<p><a href="${href}">${text}</a></p>\n<!-- /wp:paragraph -->`;
|
| }
|
|
|
| case "div": {
|
| const cls = extractAttr(el.attrs, "class") || "";
|
| // cleaner → удалить
|
| if (cls.includes("cleaner")) return null;
|
| // div с классом → group
|
| const innerBlocks = htmlToGutenberg(el.inner);
|
| if (!innerBlocks.trim()) return null;
|
| const className = cls.split(/\s+/).filter(Boolean).join(" ");
|
| const atts = className ? ` {"className":"${className}"}` : "";
|
| return `<!-- wp:group${atts} -->\n<div class="wp-block-group${className ? " " + className : ""}">\n${innerBlocks}\n</div>\n<!-- /wp:group -->`;
|
| }
|
|
|
| case "figure": {
|
| const innerBlocks = htmlToGutenberg(el.inner);
|
| if (!innerBlocks.trim()) return null;
|
| return `<!-- wp:group -->\n<figure class="wp-block-group">\n${innerBlocks}\n</figure>\n<!-- /wp:group -->`;
|
| }
|
|
|
| case "blockquote": {
|
| const cleanInner = sanitizeForGutenberg(el.inner);
|
| return `<!-- wp:quote -->\n<blockquote class="wp-block-quote">${cleanInner}</blockquote>\n<!-- /wp:quote -->`;
|
| }
|
|
|
| case "hr": {
|
| return `<!-- wp:separator -->\n<hr class="wp-block-separator"/>\n<!-- /wp:separator -->`;
|
| }
|
|
|
| default: {
|
| // нераспознанное → очистить и в wp:html
|
| const content = sanitizeForGutenberg(el.raw || el.content || "");
|
| if (!content) return null;
|
| return `<!-- wp:html -->\n${content}\n<!-- /wp:html -->`;
|
| }
|
| }
|
| }
|
|
|
| function extractAlign(attrs) {
|
| const m = attrs.match(/style=["'][^"']*\btext-align:\s*(left|right|center)\b[^"']*["']/i);
|
| return m ? m[1] : null;
|
| }
|
| function extractAttr(attrs, name) {
|
| const re = new RegExp(`\\b${name}=["']([^"']*)["']`, "i");
|
| const m = attrs.match(re);
|
| return m ? m[1] : null;
|
| }
|
| function extractStyleValue(attrs, prop) {
|
| const m = attrs.match(new RegExp(`\\bstyle=["'][^"']*\\b${prop}:\\s*(\\d+)`, "i"));
|
| return m ? m[1] : null;
|
| }
|
|
|
| async function writeReport(file, manifest, logs, assetMap) {
|
| const rows = manifest.staticPages.map((page) => `<tr><td>${escapeHtml(page.path)}</td><td>${escapeHtml(page.title)}</td><td>${escapeHtml(page.timestamp)}</td><td>${escapeHtml(page.sourceUrl)}</td></tr>`).join("");
|
| const validation = manifest.validation || { binRenamed: 0, brokenLinks: [], totalAssetsChecked: 0 };
|
| const brokenRows = validation.brokenLinks.length
|
| ? validation.brokenLinks.map((b) => `<tr><td>${escapeHtml(b.url)}</td><td>${escapeHtml(b.source)}</td><td>${escapeHtml(String(b.status))}</td><td>${escapeHtml(b.reason)}</td></tr>`).join("")
|
| : '<tr><td colspan="4" style="color:#058d28">Битых ссылок не найдено ✓</td></tr>';
|
| const html = `<!doctype html><html><head><meta charset="utf-8"><title>Archive Build Report</title><style>body{font-family:Arial,sans-serif;max-width:1100px;margin:40px auto;padding:0 20px;line-height:1.5}table{width:100%;border-collapse:collapse;margin:12px 0}td,th{border:1px solid #ddd;padding:8px;text-align:left}code,pre{background:#f4f4f4;padding:2px 4px}pre{white-space:pre-wrap;padding:14px}.summary{background:#f0f7ff;padding:14px;border-radius:6px;margin:16px 0}.broken{color:#c00}</style></head><body><h1>Archive Build Report</h1><div class="summary"><b>Static pages:</b> ${manifest.staticPages.length} | <b>Assets:</b> ${assetMap.size} | <b>Casino pages:</b> ${manifest.casino.pages.length}<br><b>Validation:</b> .bin→.html переименовано: ${validation.binRenamed} | Проверено ссылок: ${validation.totalAssetsChecked} | <span class="${validation.brokenLinks.length ? 'broken' : ''}">Битых: ${validation.brokenLinks.length}</span></div><h2>Pages</h2><table><thead><tr><th>Path</th><th>Title</th><th>Snapshot</th><th>Source</th></tr></thead><tbody>${rows}</tbody></table><h2>Broken Links</h2><table><thead><tr><th>URL</th><th>Source</th><th>Status</th><th>Reason</th></tr></thead><tbody>${brokenRows}</tbody></table><h2>Design Tokens</h2><pre>${escapeHtml(JSON.stringify(manifest.design, null, 2))}</pre><h2>Design Assets</h2><pre>${escapeHtml(JSON.stringify(manifest.designAssets || {}, null, 2))}</pre><h2>Log</h2><pre>${escapeHtml(logs.join("\n"))}</pre></body></html>`;
|
| await fs.writeFile(file, html, "utf8");
|
| }
|
|
|
| /**
|
| * Валидация собранных данных:
|
| * 1. Находит .bin файлы, которые на самом деле HTML, и переименовывает в .html.
|
| * Попутно исправляет ссылки в архивном HTML.
|
| * 2. Проверяет все ассеты на битые ссылки (404/недоступные).
|
| */
|
| async function validateBuild(assetDir, staticPages, progressLogs) {
|
| const result = { binRenamed: 0, brokenLinks: [], totalAssetsChecked: 0 };
|
|
|
| // --- 1. .bin → .html ---
|
| try {
|
| const assetFiles = await listFilesRecursive(assetDir);
|
| const renames = []; // [{oldFile, newFile, oldName, newName}]
|
|
|
| for (const file of assetFiles) {
|
| if (!file.endsWith(".bin")) continue;
|
| const data = await fs.readFile(file);
|
| const sample = data.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
|
| if (sample.startsWith("<!doctype html") || sample.startsWith("<html")) {
|
| // это HTML — переименуем в .html
|
| const newName = path.basename(file, ".bin") + ".html";
|
| const newFile = path.join(path.dirname(file), newName);
|
| await fs.rename(file, newFile);
|
| renames.push({
|
| oldName: path.basename(file),
|
| newName,
|
| oldFile: file,
|
| newFile,
|
| });
|
| result.binRenamed++;
|
| }
|
| }
|
|
|
| // исправить ссылки в архивном HTML: .bin → .html
|
| if (renames.length > 0) {
|
| for (const sp of staticPages) {
|
| const htmlFile = path.join(path.dirname(assetDir), "static-html", sp.file);
|
| try {
|
| let html = await fs.readFile(htmlFile, "utf8");
|
| let changed = false;
|
| for (const r of renames) {
|
| if (html.includes(r.oldName)) {
|
| html = html.split(r.oldName).join(r.newName);
|
| changed = true;
|
| }
|
| }
|
| if (changed) {
|
| await fs.writeFile(htmlFile, html, "utf8");
|
| }
|
| } catch { /* файл мог быть удалён */ }
|
| }
|
| // также обновить assetMap — но он не доступен здесь; обновим через manifest позже
|
| }
|
| } catch (e) {
|
| progressLogs.push(`Validation .bin error: ${e.message}`);
|
| }
|
|
|
| // --- 2. Проверка битых ссылок (асинхронно, с ограничением параллельности) ---
|
| // Собираем все URL из архивного HTML и CSS, проверяем HEAD-запросом.
|
| // Внимание: проверяем только внешние/архивные ссылки, не локальные ассеты.
|
| try {
|
| const checked = new Set();
|
| const toCheck = [];
|
|
|
| for (const sp of staticPages) {
|
| const htmlFile = path.join(path.dirname(assetDir), "static-html", sp.file);
|
| let html = "";
|
| try { html = await fs.readFile(htmlFile, "utf8"); } catch { continue; }
|
|
|
| // найти все href/src, ведущие на http/https
|
| const matches = [...html.matchAll(/\b(?:href|src)=["']([^"']+)["']/gi)];
|
| for (const m of matches) {
|
| const url = m[1].split("#")[0].split("?")[0];
|
| if (!url.startsWith("http")) continue;
|
| if (checked.has(url)) continue;
|
| checked.add(url);
|
| // не проверяем wayback ссылки — они всегда отвечают
|
| if (url.includes("web.archive.org")) continue;
|
| toCheck.push({ url, source: sp.path });
|
| }
|
| }
|
|
|
| result.totalAssetsChecked = toCheck.length;
|
| progressLogs.push(`Validation: проверяю ${toCheck.length} внешних ссылок...`);
|
|
|
| // проверяем пакетами по 5
|
| const BATCH = 5;
|
| for (let i = 0; i < toCheck.length; i += BATCH) {
|
| const batch = toCheck.slice(i, i + BATCH);
|
| const results = await Promise.allSettled(
|
| batch.map(async (item) => {
|
| try {
|
| const controller = new AbortController();
|
| const timer = setTimeout(() => controller.abort(), 8000);
|
| const r = await fetch(item.url, {
|
| method: "HEAD",
|
| signal: controller.signal,
|
| redirect: "follow",
|
| });
|
| clearTimeout(timer);
|
| return { ...item, status: r.status, ok: r.ok };
|
| } catch (e) {
|
| return { ...item, status: 0, ok: false, reason: e.name === "AbortError" ? "timeout" : "unreachable" };
|
| }
|
| })
|
| );
|
| for (const r of results) {
|
| if (r.status === "fulfilled") {
|
| const val = r.value;
|
| if (!val.ok && val.status !== 405 && val.status !== 403) {
|
| // 405/403 часто отдаются для HEAD, хотя GET работает — не считаем битыми
|
| result.brokenLinks.push({
|
| url: val.url,
|
| source: val.source,
|
| status: val.status || val.reason || "unknown",
|
| reason: val.status === 404 ? "404 Not Found"
|
| : val.status === 0 ? "недоступен/timeout"
|
| : `HTTP ${val.status}`,
|
| });
|
| }
|
| }
|
| }
|
| }
|
| } catch (e) {
|
| progressLogs.push(`Validation links error: ${e.message}`);
|
| }
|
|
|
| return result;
|
| }
|
|
|
| function extractDesign(css) {
|
| const colors = {};
|
| for (const match of css.matchAll(/#[0-9a-f]{3,8}\b/gi)) {
|
| const color = normalizeHex(match[0]);
|
| colors[color] = (colors[color] || 0) + 1;
|
| }
|
| const ranked = Object.entries(colors).sort((a, b) => b[1] - a[1]).map(([color]) => color);
|
| const fontMatch = css.match(/font-family\s*:\s*([^;{}]+)/i);
|
| return {
|
| primary: ranked[0] || "#224f9f",
|
| accent: ranked[1] || ranked[0] || "#d14b2f",
|
| background: "#ffffff",
|
| text: "#1b1f24",
|
| font: fontMatch ? fontMatch[1].replace(/["']/g, "").trim() : "Arial, sans-serif"
|
| };
|
| }
|
|
|
| /**
|
| * Извлечь designAssets из assetMap: hash'и для master.css, reset.css,
|
| * фоновой картинки body, логотипа. Нужны чтобы стилизовать Casino-страницы
|
| * под оригинальный дизайн на любом сайте.
|
| */
|
| function extractDesignAssets(assetMap, cssTexts) { |
| const result = { cssMaster: "", cssReset: "", bgImage: "", logo: "", hero: "", sidebarHeader: "", bgBody: "", bgTop: "", bgInsertPage: "", bgFooter: "" }; |
|
|
| // найти master.css и reset.css в assetMap по паттернам имени
|
| for (const [url, info] of assetMap) {
|
| const lower = url.toLowerCase();
|
| if (/master\.css/i.test(lower) && !result.cssMaster) result.cssMaster = info.file;
|
| if (/reset\.css/i.test(lower) && !result.cssReset) result.cssReset = info.file;
|
| if (/logo/i.test(lower) && /\.(png|jpg|gif|svg)/i.test(info.file) && !result.logo) result.logo = info.file; |
| if (/(?:slidery|slider|slide)/i.test(lower) && /\.(png|jpe?g|webp)/i.test(info.file) && !result.hero) result.hero = info.file; |
| if (/(?:\/|_)restaurace1?\.(?:png|jpe?g|webp)/i.test(lower) && !result.sidebarHeader) result.sidebarHeader = info.file; |
| if (/bg_page|bg-body/i.test(lower) && !result.bgImage) result.bgImage = info.file;
|
| if (/bg_header/i.test(lower) && !result.bgTop) result.bgTop = info.file;
|
| if (/bg_insert_page/i.test(lower) && !result.bgInsertPage) result.bgInsertPage = info.file;
|
| if (/bg_paticka|bg_footer/i.test(lower) && !result.bgFooter) result.bgFooter = info.file;
|
| }
|
|
|
| // из CSS найти фоновую картинку body (url(...) в правиле body{...})
|
| const allCss = cssTexts.join("\n");
|
| const bodyMatch = allCss.match(/body\s*\{[^}]*background\s*:\s*url\(([^)]+)\)/i);
|
| if (bodyMatch && bodyMatch[1]) {
|
| // найти этот файл в assetMap
|
| const bgOrig = bodyMatch[1].replace(/^["']|["']$/g, "");
|
| const bgBase = bgOrig.split("/").pop();
|
| for (const [url, info] of assetMap) {
|
| if (url.endsWith(bgBase) || url.includes(bgBase)) { result.bgImage = info.file; break; }
|
| }
|
| }
|
|
|
| return result;
|
| }
|
|
|
| function shouldDownloadAsset(raw, attr) {
|
| if (!raw || raw.startsWith("#") || /^(data:|mailto:|tel:|javascript:)/i.test(raw)) return false;
|
| if (attr === "href" && !/\.(css|ico|png|jpe?g|gif|webp|svg|woff2?|ttf|otf)(\?|#|$)/i.test(raw)) return false;
|
| return true;
|
| }
|
|
|
| function toAbsoluteAssetUrl(raw, base) {
|
| try {
|
| if (/^\/web\/\d+/i.test(raw)) {
|
| const parsed = parseWaybackUrl(`https://web.archive.org${raw}`);
|
| if (parsed) return parsed.original;
|
| }
|
| if (/web\.archive\.org\/web\/\d+/i.test(raw)) {
|
| const parsed = parseWaybackUrl(raw);
|
| if (parsed) return parsed.original;
|
| }
|
| return new URL(raw, base).href;
|
| } catch {
|
| return null;
|
| }
|
| }
|
|
|
| async function fetchText(url) {
|
| const response = await fetchWithTimeout(url, HTML_TIMEOUT_MS);
|
| if (!response.ok) throw new Error(`Fetch failed ${response.status}: ${url}`);
|
| return response.text();
|
| }
|
|
|
| async function fetchJson(url) {
|
| const response = await fetchWithTimeout(url, HTML_TIMEOUT_MS);
|
| if (!response.ok) throw new Error(`Fetch failed ${response.status}: ${url}`);
|
| return response.json();
|
| }
|
|
|
| async function fetchWithTimeout(url, timeoutMs, retries = 3) {
|
| let lastError;
|
| for (let attempt = 1; attempt <= retries; attempt++) {
|
| const controller = new AbortController();
|
| const timer = setTimeout(() => controller.abort(), timeoutMs);
|
| try {
|
| const response = await fetch(url, { redirect: "follow", signal: controller.signal });
|
| clearTimeout(timer);
|
| if (response.ok || response.status === 404) return response;
|
| // 5xx — повторить
|
| if (response.status >= 500 && attempt < retries) {
|
| await new Promise(r => setTimeout(r, 2000 * attempt));
|
| continue;
|
| }
|
| return response;
|
| } catch (e) {
|
| clearTimeout(timer);
|
| lastError = e;
|
| if (attempt < retries) {
|
| const wait = 3000 * attempt;
|
| console.error(`[retry ${attempt}/${retries}] ${e.message} — ждём ${wait}мс...`);
|
| await new Promise(r => setTimeout(r, wait));
|
| continue;
|
| }
|
| }
|
| }
|
| throw lastError || new Error("fetch failed after retries");
|
| }
|
|
|
| async function zipDirectory(sourceDir, destination, options = {}) { |
| await fs.rm(destination, { force: true }); |
| const python = [ |
| "import os,sys,zipfile", |
| "source=os.path.abspath(sys.argv[1])", |
| "destination=os.path.abspath(sys.argv[2])", |
| "include_root=sys.argv[3]=='1'", |
| "prefix=os.path.basename(source) if include_root else ''", |
| "with zipfile.ZipFile(destination,'w',zipfile.ZIP_DEFLATED) as archive:", |
| " for root,dirs,files in os.walk(source):", |
| " dirs.sort(); files.sort()", |
| " for name in files:", |
| " full=os.path.join(root,name)", |
| " rel=os.path.relpath(full,source).replace(os.sep,'/')", |
| " arcname=(prefix+'/'+rel) if prefix else rel", |
| " archive.write(full,arcname)" |
| ].join("\n"); |
| await new Promise((resolve, reject) => { |
| const child = spawn("python", ["-c", python, sourceDir, destination, options.includeRoot ? "1" : "0"], { stdio: "pipe" }); |
| let stderr = ""; |
| child.stderr.on("data", (data) => { stderr += data.toString(); }); |
| child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(stderr || `ZIP creation exited ${code}`))); |
| }); |
| } |
|
|
| async function sendFile(res, file, attachment = false) {
|
| try {
|
| const data = await fs.readFile(file);
|
| const mime = mimeForAssetFile(file, data);
|
| res.writeHead(200, {
|
| "Content-Type": mime,
|
| "Content-Disposition": attachment ? `attachment; filename="${path.basename(file)}"` : `inline; filename="${path.basename(file)}"`
|
| });
|
| res.end(data);
|
| } catch {
|
| notFound(res);
|
| }
|
| }
|
|
|
| function readJson(req) {
|
| return new Promise((resolve, reject) => {
|
| let data = "";
|
| req.on("data", (chunk) => { data += chunk; });
|
| req.on("end", () => {
|
| try { resolve(JSON.parse(data || "{}")); } catch (error) { reject(error); }
|
| });
|
| req.on("error", reject);
|
| });
|
| }
|
|
|
| function sendJson(res, data, status = 200) {
|
| res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
| res.end(JSON.stringify(data, null, 2));
|
| }
|
|
|
| function notFound(res) {
|
| res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
| res.end("Not found");
|
| }
|
|
|
| function normalizeOriginalUrl(value) {
|
| const url = String(value).trim();
|
| if (!/^https?:\/\//i.test(url)) return `https://${url}`;
|
| return url;
|
| }
|
|
|
| function cleanDomain(value) { |
| return String(value || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "").trim(); |
| } |
|
|
| function rewriteInternalSiteUrls(html, sourceDomain, targetDomain) { |
| const sourceHost = cleanDomain(sourceDomain).replace(/^www\./i, ""); |
| if (!sourceHost) return html; |
| const targetBase = targetDomain ? `https://${cleanDomain(targetDomain)}` : ""; |
| const sourcePattern = new RegExp(`https?:\\/\\/(?:www\\.)?${escapeRegex(sourceHost)}`, "gi"); |
| return String(html).replace(sourcePattern, targetBase); |
| } |
|
|
| function normalizeRequestPath(pathname) {
|
| const trimmed = "/" + String(pathname || "/").replace(/^\/+|\/+$/g, "");
|
| return trimmed === "/" ? "/" : `${trimmed}/`;
|
| }
|
|
|
| function pathNameToFileStem(requestPath) {
|
| return requestPath === "/" ? "home" : slugify(requestPath.replace(/^\/|\/$/g, "").replaceAll("/", "-"));
|
| }
|
|
|
| function titleFromPath(requestPath) {
|
| if (requestPath === "/") return "Home";
|
| return requestPath.replace(/^\/|\/$/g, "").split("/").pop().replaceAll("-", " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
| }
|
|
|
| function extractTitle(html) {
|
| const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
| return match ? decodeEntities(match[1].replace(/\s+/g, " ").trim()) : "";
|
| }
|
|
|
| function decodeEntities(value) {
|
| // Базовые сущности
|
| value = value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/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) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[char]));
|
| }
|
|
|
| function escapeRegex(value) { |
| return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| } |
|
|
| function delay(milliseconds) { |
| return new Promise((resolve) => setTimeout(resolve, milliseconds)); |
| } |
|
|
| function validateFullStaticDocument(html, label = "static page") { |
| const value = String(html || ""); |
| const missing = ["html", "head", "body"].filter((tag) => !new RegExp(`<${tag}\\b`, "i").test(value)); |
| if (missing.length) throw new Error(`${label} is not a full HTML document (missing ${missing.join(", ")}).`); |
| return true; |
| } |
|
|
| function validateNoUnresolvedAssetReferences(html, label = "static page") { |
| const unresolved = new Set(); |
| for (const match of String(html || "").matchAll(/\b(?:src|href)=(['"])([^'"]+)\1/gi)) { |
| const raw = match[2].trim(); |
| if (!raw || /^(?:data:|#|mailto:|tel:|javascript:|\{\{ACI_ASSET_URL\}\})/i.test(raw)) continue; |
| const pathname = raw.split(/[?#]/, 1)[0]; |
| if (/\.(?:css|js|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|pdf|mp4|webm)$/i.test(pathname)) unresolved.add(raw); |
| } |
| if (unresolved.size) { |
| throw new Error(`${label} still contains remote or unresolved asset URLs: ${[...unresolved].slice(0, 10).join(", ")}`); |
| } |
| return true; |
| } |
|
|
| async function validateStaticAssetReferences(html, assetDir, label = "static page") { |
| const names = new Set(); |
| for (const match of String(html || "").matchAll(/\{\{ACI_ASSET_URL\}\}\/([^\s"'?#<>]+)/g)) { |
| names.add(path.basename(match[1])); |
| } |
| const missing = []; |
| for (const name of names) { |
| try { |
| await fs.access(path.join(assetDir, name)); |
| } catch { |
| missing.push(name); |
| } |
| } |
| if (missing.length) throw new Error(`${label} references missing local assets: ${missing.join(", ")}`); |
| return names.size; |
| } |
|
|
| export { |
| applySafeTextReplacements, |
| discoverArchiveUrls, |
| ensureEditableContentMarkers, |
| ensureEditableContentWrapper, |
| ensureEditableFooterMarkers, |
| extractEditableFooterContent, |
| normalizeConfig, |
| parseCasinoPages, |
| parseMenuItems, |
| parseTextReplacements, |
| replaceEditableContentRegion, |
| replaceEditableFooterRegion, |
| sanitizeAiProposal, |
| validateEditableHtmlFragment, |
| validateFullStaticDocument, |
| validateNoUnresolvedAssetReferences, |
| validateStaticAssetReferences, |
| writeTheme, |
| zipDirectory |
| }; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|