| import http from "node:http"; |
| import { promises as fs } from "node:fs"; |
| import path from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| import crypto from "node:crypto"; |
| import { spawn } from "node:child_process"; |
|
|
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| const outputsDir = path.join(__dirname, "outputs"); |
| const publicDir = path.join(__dirname, "public"); |
| const buildRoot = path.join(__dirname, "build"); |
| const port = Number(process.env.PORT || 8765); |
| const HTML_TIMEOUT_MS = 20000; |
| const ASSET_TIMEOUT_MS = 12000; |
| const MAX_ASSETS = 250; |
|
|
| const mimeTypes = { |
| ".html": "text/html; charset=utf-8", |
| ".css": "text/css; charset=utf-8", |
| ".js": "application/javascript; charset=utf-8", |
| ".json": "application/json; charset=utf-8", |
| ".ico": "image/x-icon", |
| ".jpg": "image/jpeg", |
| ".jpeg": "image/jpeg", |
| ".png": "image/png", |
| ".gif": "image/gif", |
| ".webp": "image/webp", |
| ".svg": "image/svg+xml", |
| ".woff": "font/woff", |
| ".woff2": "font/woff2", |
| ".ttf": "font/ttf", |
| ".zip": "application/zip" |
| }; |
|
|
| let progressState = { |
| active: false, |
| buildId: null, |
| updatedAt: null, |
| logs: [], |
| summary: null, |
| awaitingApproval: false, |
| preview: null |
| }; |
| const previewSessions = new Map(); |
| const approvalWaiters = new Map(); |
|
|
| const server = http.createServer(async (req, res) => { |
| try { |
| const url = new URL(req.url, `http://${req.headers.host}`); |
|
|
| if (req.method === "GET" && url.pathname === "/api/status") { |
| return sendJson(res, { ok: true, port, outputsDir }); |
| } |
|
|
| if (req.method === "GET" && url.pathname === "/api/progress") { |
| return sendJson(res, { ok: true, ...progressState }); |
| } |
|
|
| if (req.method === "POST" && url.pathname === "/api/continue") { |
| const body = await readJson(req); |
| const waiter = approvalWaiters.get(body.buildId); |
| if (!waiter) return sendJson(res, { ok: false, error: "No build is waiting for approval." }, 404); |
| approvalWaiters.delete(body.buildId); |
| progressState.awaitingApproval = false; |
| progressState.preview = null; |
| progressState.updatedAt = new Date().toISOString(); |
| waiter(); |
| return sendJson(res, { ok: true }); |
| } |
|
|
| if (req.method === "POST" && url.pathname === "/api/build") { |
| const body = await readJson(req); |
| const result = await buildProject(body); |
| return sendJson(res, result); |
| } |
|
|
| if (req.method === "GET" && url.pathname.startsWith("/download/")) { |
| const name = path.basename(decodeURIComponent(url.pathname.replace("/download/", ""))); |
| const file = path.join(outputsDir, name); |
| if (!file.startsWith(outputsDir)) return notFound(res); |
| return sendFile(res, file, true); |
| } |
|
|
| if (req.method === "GET" && url.pathname.startsWith("/preview/")) { |
| return sendPreviewFile(res, url.pathname); |
| } |
|
|
| |
|
|
| |
| if (req.method === "GET" && url.pathname === "/api/pages") { |
| const buildId = url.searchParams.get("buildId"); |
| const session = previewSessions.get(buildId); |
| if (!session) return sendJson(res, { ok: false, error: "Build session not found" }, 404); |
| const manifest = await getBuildManifest(buildId); |
| const pages = (manifest?.staticPages || []).map((p) => ({ |
| title: p.title, |
| path: p.path, |
| file: p.file, |
| url: `/preview/${buildId}/pages/${encodeURIComponent(p.file)}` |
| })); |
| return sendJson(res, { ok: true, buildId, pages }); |
| } |
|
|
| |
| if (req.method === "GET" && url.pathname === "/api/page") { |
| const buildId = url.searchParams.get("buildId"); |
| const file = url.searchParams.get("file"); |
| const session = previewSessions.get(buildId); |
| if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400); |
| const filePath = path.join(session.staticDir, path.basename(file)); |
| if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); |
| try { |
| let html = await fs.readFile(filePath, "utf8"); |
| |
| html = html.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`); |
| html = html.replaceAll("{{ACI_SITE_URL}}", "/"); |
| return sendJson(res, { ok: true, file, content: html }); |
| } catch { |
| return sendJson(res, { ok: false, error: "File not found" }, 404); |
| } |
| } |
|
|
| |
| if (req.method === "POST" && url.pathname === "/api/page") { |
| const body = await readJson(req); |
| const { buildId, file, content } = body; |
| const session = previewSessions.get(buildId); |
| if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400); |
| const filePath = path.join(session.staticDir, path.basename(file)); |
| if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); |
| |
| let html = content; |
| html = html.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}"); |
| html = html.replaceAll("/", "{{ACI_SITE_URL}}").replaceAll("{{ACI_SITE_URL}}/", "/"); |
| await fs.writeFile(filePath, html, "utf8"); |
| |
| await updateBuildManifest(buildId, (manifest) => { |
| const page = manifest.staticPages.find((p) => p.file === file); |
| if (page) { |
| const parts = splitPageHtml(html, manifest.contentSelector); |
| page.gutenbergContent = htmlToGutenberg(parts.contentHtml); |
| } |
| }); |
| return sendJson(res, { ok: true }); |
| } |
|
|
| const requested = url.pathname === "/" ? "/index.html" : url.pathname; |
| const file = path.join(publicDir, decodeURIComponent(requested)); |
| if (!file.startsWith(publicDir)) return notFound(res); |
| return sendFile(res, file); |
| } catch (error) { |
| sendJson(res, { ok: false, error: error.message, stack: error.stack }, 500); |
| } |
| }); |
|
|
| server.listen(port, () => { |
| console.log(`Archive Casino WP Builder is running at http://localhost:${port}/`); |
| }); |
|
|
| async function buildProject(input) { |
| const config = normalizeConfig(input); |
| const buildId = `${Date.now()}-${slugify(config.targetDomain || "site")}`; |
| const buildDir = path.join(buildRoot, buildId); |
| const themeDir = path.join(buildDir, slugify(config.themeName)); |
| const dataDir = path.join(themeDir, "data"); |
| const staticDir = path.join(dataDir, "static-html"); |
| const assetDir = path.join(dataDir, "archive-assets"); |
| const logs = []; |
| progressState = { |
| active: true, |
| buildId, |
| updatedAt: new Date().toISOString(), |
| logs: [], |
| summary: { pages: 0, assets: 0, casinoPages: config.casinoPages.length }, |
| awaitingApproval: false, |
| preview: null |
| }; |
| const progressLogs = { |
| push(message) { |
| logs.push(message); |
| setProgress(message, { summary: { pages: staticPages.length, assets: assetMap.size, casinoPages: config.casinoPages.length } }); |
| } |
| }; |
|
|
| await fs.mkdir(outputsDir, { recursive: true }); |
| await fs.mkdir(staticDir, { recursive: true }); |
| await fs.mkdir(assetDir, { recursive: true }); |
| await fs.mkdir(themeDir, { recursive: true }); |
|
|
| const assetMap = new Map(); |
| const cssTexts = []; |
| const staticPages = []; |
| previewSessions.set(buildId, { buildDir, staticDir, assetDir }); |
|
|
| let shellHtmlGlobal = ""; |
| let footerHtmlGlobal = ""; |
|
|
| for (const sourceUrl of config.urls) { |
| progressLogs.push(`Resolving ${sourceUrl}`); |
| const snapshot = await resolveSnapshot(sourceUrl, config); |
| progressLogs.push(`Using snapshot ${snapshot.timestamp} for ${snapshot.original}`); |
|
|
| const htmlUrl = `https://web.archive.org/web/${snapshot.timestamp}id_/${snapshot.original}`; |
| const rawHtml = await fetchText(htmlUrl); |
| let html = cleanWaybackHtml(rawHtml); |
|
|
| html = await rewriteHtmlAssets(html, snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs); |
| html = html |
| .replaceAll(snapshot.original, config.targetDomain ? `https://${config.targetDomain}` : snapshot.original) |
| .replaceAll("{{ACI_SITE_URL}}", ""); |
|
|
| |
| const cleaned = await cleanExternalLinks(html, config.sourceDomain, progressLogs); |
| html = cleaned.cleanedHtml; |
|
|
| |
| const parts = splitPageHtml(html, config.contentSelector); |
| 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}`); |
| } |
|
|
| |
| config._shellHtml = shellHtmlGlobal; |
| config._footerHtml = footerHtmlGlobal; |
|
|
| const design = extractDesign(cssTexts.join("\n")); |
| const designAssets = extractDesignAssets(assetMap, cssTexts); |
| const manifest = { |
| generatedAt: new Date().toISOString(), |
| sourceDomain: config.sourceDomain, |
| targetDomain: config.targetDomain, |
| staticPages, |
| menuItems: config.menuItems, |
| casino: { |
| menuLabel: config.casinoMenuLabel, |
| rootPath: config.casinoRootPath, |
| pages: config.casinoPages |
| }, |
| design, |
| designAssets |
| }; |
|
|
| const previewPages = staticPages.map((page) => ({ |
| title: page.title, |
| path: page.path, |
| url: `/preview/${buildId}/pages/${encodeURIComponent(page.file)}` |
| })); |
| progressState.awaitingApproval = true; |
| progressState.preview = { buildId, pages: previewPages }; |
| progressState.updatedAt = new Date().toISOString(); |
| progressLogs.push("Preview ready. Review pages and click Continue build."); |
| await waitForPreviewApproval(buildId); |
| progressLogs.push("Preview approved. Building WordPress package."); |
|
|
| await fs.writeFile(path.join(dataDir, "pages.json"), JSON.stringify(manifest, null, 2), "utf8"); |
| await writeTheme(themeDir, config, manifest); |
| progressLogs.push("Theme-only WordPress package generated"); |
|
|
| const themeZipName = `${slugify(config.themeName)}.zip`; |
| const reportName = `archive-build-report-${buildId}.html`; |
|
|
| await zipDirectory(themeDir, path.join(outputsDir, themeZipName), { includeRoot: false }); |
| progressLogs.push(`ZIP ready: ${themeZipName}`); |
| await writeReport(path.join(outputsDir, reportName), manifest, logs, assetMap); |
| progressLogs.push(`Report ready: ${reportName}`); |
|
|
| progressState.active = false; |
| progressState.updatedAt = new Date().toISOString(); |
| progressState.summary = { |
| pages: staticPages.length, |
| assets: assetMap.size, |
| casinoPages: config.casinoPages.length |
| }; |
|
|
| return { |
| ok: true, |
| buildId, |
| files: [ |
| { name: themeZipName, url: `/download/${themeZipName}` }, |
| { name: reportName, url: `/download/${reportName}` } |
| ], |
| summary: { |
| pages: staticPages.length, |
| assets: assetMap.size, |
| casinoPages: config.casinoPages.length |
| }, |
| logs |
| }; |
| } |
|
|
| function setProgress(message, patch = {}) { |
| const time = new Date().toLocaleTimeString("ru-RU", { hour12: false }); |
| progressState.logs.push(`[${time}] ${message}`); |
| if (progressState.logs.length > 500) progressState.logs.shift(); |
| progressState = { |
| ...progressState, |
| ...patch, |
| updatedAt: new Date().toISOString() |
| }; |
| } |
|
|
| function waitForPreviewApproval(buildId) { |
| return new Promise((resolve) => { |
| approvalWaiters.set(buildId, resolve); |
| }); |
| } |
|
|
| |
| 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") { |
| const file = path.join(session.staticDir, name); |
| if (!file.startsWith(session.staticDir)) return notFound(res); |
| try { |
| const html = await fs.readFile(file, "utf8"); |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); |
| res.end(html |
| .replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`) |
| .replaceAll("{{ACI_SITE_URL}}", "/")); |
| } catch { |
| notFound(res); |
| } |
| return; |
| } |
|
|
| if (kind === "assets") { |
| const file = path.join(session.assetDir, name); |
| if (!file.startsWith(session.assetDir)) return notFound(res); |
| return sendFile(res, file); |
| } |
|
|
| return notFound(res); |
| } |
|
|
| const DEFAULT_STATIC_MENU = "/cz/1-O-restauraci/ | O restauraci\n/cz/9-Poledni-menu/ | Polední menu\n/cz/15-Fotogalerie/ | Fotogalerie\n/cz/17-Kontakty/ | Kontakty"; |
|
|
| function parseMenuItems(value) { |
| return String(value || DEFAULT_STATIC_MENU) |
| .split(/\r?\n/) |
| .map((line) => line.trim()) |
| .filter(Boolean) |
| .map((line) => { |
| const parts = line.split("|").map((part) => part.trim()).filter(Boolean); |
| let pathValue = parts.length >= 2 ? parts[0] : line; |
| let title = parts.length >= 2 ? parts.slice(1).join(" | ") : titleFromPath(pathValue); |
| pathValue = pathValue.replace(/^https?:\/\/[^/]+/i, ""); |
| if (!pathValue.startsWith("/")) pathValue = "/" + pathValue; |
| if (!pathValue.endsWith("/")) pathValue += "/"; |
| return { title: title || pathValue, path: pathValue.replace(/\/+/g, "/") }; |
| }); |
| } |
|
|
| const DEFAULT_CASINO_PAGES = "/casino/ | Casino\n/casino/neteller-casino/ | Neteller Casino\n/casino/skrill-casino/ | Skrill Casino"; |
|
|
| function parseCasinoPages(value, nestedCasino = true) { |
| const lines = String(value || DEFAULT_CASINO_PAGES) |
| .split(/\r?\n/) |
| .map((line) => line.trim()) |
| .filter(Boolean); |
|
|
| const pages = lines.map((line, index) => parseCasinoPageLine(line, index, nestedCasino)); |
| |
| |
|
|
| const seen = new Set(); |
| return pages.filter((page) => { |
| if (seen.has(page.path)) return false; |
| seen.add(page.path); |
| return true; |
| }); |
| } |
|
|
| function parseCasinoPageLine(line, index, nestedCasino) { |
| const parts = line.split("|").map((part) => part.trim()).filter(Boolean); |
| let pathPart = ""; |
| let titlePart = ""; |
|
|
| if (parts.length >= 2) { |
| const leftLooksPath = looksLikePath(parts[0]); |
| const rightLooksPath = looksLikePath(parts[1]); |
| if (leftLooksPath || !rightLooksPath) { |
| pathPart = parts[0]; |
| titlePart = parts.slice(1).join(" | "); |
| } else { |
| titlePart = parts[0]; |
| pathPart = parts[1]; |
| } |
| } else if (looksLikePath(line)) { |
| pathPart = line; |
| titlePart = titleFromPath(line); |
| } else { |
| titlePart = line; |
| } |
|
|
| const title = titlePart || titleFromPath(pathPart) || "Casino"; |
| const path = normalizeCasinoPath(pathPart, title, index, nestedCasino); |
| const slug = slugFromCasinoPath(path); |
| return { title, slug, path }; |
| } |
|
|
| function looksLikePath(value) { |
| return /^\/?casino(?:\/|$)/i.test(value) || /^\//.test(value) || /\//.test(value); |
| } |
|
|
| function normalizeCasinoPath(pathPart, title, index, nestedCasino) { |
| let pathValue = String(pathPart || "").trim(); |
| if (!pathValue) { |
| if (index === 0) return "/casino/"; |
| const slug = slugify(title.replace(/^casino\s+/i, "")); |
| return nestedCasino ? "/casino/" + slug + "/" : "/" + slug + "/"; |
| } |
| pathValue = pathValue.replace(/^https?:\/\/[^/]+/i, ""); |
| if (!pathValue.startsWith("/")) pathValue = "/" + pathValue; |
| if (!pathValue.endsWith("/")) pathValue += "/"; |
| return pathValue.replace(/\/+/g, "/"); |
| } |
|
|
| function slugFromCasinoPath(pathValue) { |
| const clean = String(pathValue || "/casino/").replace(/^\/+|\/+$/g, ""); |
| const last = clean.split("/").filter(Boolean).pop() || "casino"; |
| return slugify(last) || "casino"; |
| } |
|
|
| function normalizeConfig(input) { |
| const urls = String(input.urls || "") |
| .split(/\r?\n|,/) |
| .map((value) => value.trim()) |
| .filter(Boolean); |
|
|
| if (!urls.length) throw new Error("Добавьте хотя бы один URL."); |
|
|
| const casinoPages = parseCasinoPages(input.casinoPages, input.nestedCasino !== false); |
| const menuItems = parseMenuItems(input.staticMenuItems); |
|
|
| return { |
| sourceDomain: cleanDomain(input.sourceDomain), |
| targetDomain: cleanDomain(input.targetDomain), |
| snapshotMode: input.snapshotMode || "latest", |
| snapshotDate: input.snapshotDate || "", |
| snapshotTimestamp: input.snapshotTimestamp || "", |
| themeName: input.themeName || "Recovered Casino Theme", |
| casinoMenuLabel: input.casinoMenuLabel || "Casino", |
| casinoRootPath: "/casino/", |
| menuItems, |
| nestedCasino: input.nestedCasino !== false, |
| urls, |
| casinoPages |
| }; |
| } |
|
|
| async function resolveSnapshot(rawUrl, config) { |
| const parsed = parseWaybackUrl(rawUrl); |
| if (parsed) return parsed; |
|
|
| const original = normalizeOriginalUrl(rawUrl); |
| if (config.snapshotMode === "timestamp" && /^\d{8,14}$/.test(config.snapshotTimestamp)) { |
| return { original, timestamp: config.snapshotTimestamp }; |
| } |
|
|
| const to = config.snapshotMode === "date" && config.snapshotDate |
| ? `&to=${config.snapshotDate.replaceAll("-", "")}235959` |
| : ""; |
| const cdx = `https://web.archive.org/cdx?url=${encodeURIComponent(original)}&output=json&fl=timestamp,original,statuscode,mimetype&filter=statuscode:200&filter=mimetype:text/html&collapse=digest&sort=reverse&limit=1${to}`; |
|
|
| try { |
| const rows = await fetchJson(cdx); |
| if (Array.isArray(rows) && rows.length > 1) { |
| const parsedOriginal = parseWaybackUrl(rows[1][1]); |
| return { timestamp: rows[1][0], original: parsedOriginal?.original || normalizeOriginalUrl(rows[1][1]) }; |
| } |
| } catch { |
| |
| } |
|
|
| const available = await fetchJson(`https://archive.org/wayback/available?url=${encodeURIComponent(original)}`); |
| const closest = available?.archived_snapshots?.closest; |
| if (!closest?.timestamp) throw new Error(`Не найден снимок WebArchive для ${original}`); |
| const parsedClosest = parseWaybackUrl(closest.url); |
| const closestOriginal = parsedClosest?.original || closest.url.replace(/^https?:\/\/web\.archive\.org\/web\/\d{8,14}(?:[a-z_]+)?\//i, ""); |
| return { timestamp: closest.timestamp, original: normalizeOriginalUrl(closestOriginal) }; |
| } |
|
|
| function parseWaybackUrl(value) { |
| const match = String(value).match(/web\.archive\.org\/web\/(\d{8,14})(?:[a-z_]+)?\/(https?:\/\/.+)$/i); |
| if (!match) return null; |
| return { timestamp: match[1], original: normalizeOriginalUrl(match[2]) }; |
| } |
|
|
| async function rewriteHtmlAssets(html, pageUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| const attrPattern = /\b(src|href)=["']([^"']+)["']/gi; |
| const replacements = []; |
| let match; |
|
|
| while ((match = attrPattern.exec(html))) { |
| const [full, attr, raw] = match; |
| if (!shouldDownloadAsset(raw, attr)) continue; |
| const absolute = toAbsoluteAssetUrl(raw, pageUrl); |
| if (!absolute) continue; |
| replacements.push({ full, raw, absolute, attr }); |
| } |
|
|
| for (const item of replacements) { |
| try { |
| const local = await downloadAsset(item.absolute, timestamp, assetDir, assetMap, cssTexts, logs); |
| html = html.replaceAll(item.raw, `{{ACI_ASSET_URL}}/${local}`); |
| } catch (error) { |
| logs.push(`Asset skipped: ${item.absolute} (${error.message})`); |
| } |
| } |
|
|
| return html; |
| } |
|
|
| async function downloadAsset(assetUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| if (assetMap.has(assetUrl)) return assetMap.get(assetUrl).file; |
| if (assetMap.size >= MAX_ASSETS) { |
| throw new Error(`asset limit ${MAX_ASSETS} reached`); |
| } |
|
|
| const parsed = new URL(assetUrl); |
| const ext = guessExtension(parsed.pathname); |
| const file = `${hash(assetUrl)}${ext}`; |
| const outPath = path.join(assetDir, file); |
| const archivedUrl = `https://web.archive.org/web/${timestamp}id_/${assetUrl}`; |
| const response = await fetchWithTimeout(archivedUrl, ASSET_TIMEOUT_MS); |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| let buffer = Buffer.from(await response.arrayBuffer()); |
|
|
| if (ext === ".css") { |
| let css = buffer.toString("utf8"); |
| css = await rewriteCssAssets(css, assetUrl, timestamp, assetDir, assetMap, cssTexts, logs); |
| cssTexts.push(css); |
| buffer = Buffer.from(css, "utf8"); |
| } |
|
|
| await fs.writeFile(outPath, buffer); |
| assetMap.set(assetUrl, { file, bytes: buffer.length }); |
| return file; |
| } |
|
|
| async function rewriteCssAssets(css, cssUrl, timestamp, assetDir, assetMap, cssTexts, logs) { |
| const matches = [...css.matchAll(/url\((?!['"]?data:)(?!['"]?#)['"]?([^'")]+)['"]?\)/gi)]; |
| for (const match of matches) { |
| const raw = match[1].trim(); |
| const absolute = toAbsoluteAssetUrl(raw, cssUrl); |
| if (!absolute) continue; |
| try { |
| const file = await downloadAsset(absolute, timestamp, assetDir, assetMap, cssTexts, logs); |
| css = css.replaceAll(raw, file); |
| } catch (error) { |
| logs.push(`CSS asset skipped: ${absolute} (${error.message})`); |
| } |
| } |
| return css; |
| } |
|
|
| function cleanWaybackHtml(html) { |
| return html |
| .replace(/<!--\s*BEGIN WAYBACK TOOLBAR INSERT[\s\S]*?END WAYBACK TOOLBAR INSERT\s*-->/gi, "") |
| .replace(/<script\b[^>]*(?:webarchive|archive_analytics|wombat|wayback|_static\/js)[^>]*>[\s\S]*?<\/script>/gi, "") |
| .replace(/<link\b[^>]*(?:webarchive|_static)[^>]*>/gi, "") |
| .replace(/<div[^>]+id=["']wm-ipp["'][\s\S]*?<\/div>\s*<\/div>\s*<\/div>/gi, "") |
| .replace(/<div[^>]+id=["']wm-ipp["'][\s\S]*?<\/div>/gi, "") |
| .replace(/\s*__wm\.[^<]+/gi, ""); |
| } |
|
|
| function patchCasinoMenu(html, label, href) { |
| if (new RegExp(`href=["'][^"']*${escapeRegex(href)}[^"']*["']`, "i").test(html)) return html; |
| const link = `<li class="aci-casino-menu-item"><a href="${href}">${escapeHtml(label)}</a></li>`; |
| const menuPattern = /(<(?:div|nav)[^>]+class=["'][^"']*(?:menu|nav|navigation)[^"']*["'][^>]*>[\s\S]*?<ul[^>]*>[\s\S]*?)(<\/ul>)/i; |
| if (menuPattern.test(html)) { |
| return html.replace(menuPattern, `$1${link}$2`); |
| } |
| const navPattern = /(<nav\b[\s\S]*?)(<\/nav>)/i; |
| if (navPattern.test(html)) { |
| return html.replace(navPattern, (whole, navBody, close) => { |
| if (/<\/ul>/i.test(navBody)) return `${navBody.replace(/<\/ul>/i, `${link}</ul>`)}${close}`; |
| return `${navBody}<a class="aci-casino-menu-link" href="${href}">${escapeHtml(label)}</a>${close}`; |
| }); |
| } |
| return html; |
| } |
|
|
| function chunkString(value, size = 7000) { |
| const chunks = []; |
| for (let index = 0; index < value.length; index += size) chunks.push(value.slice(index, index + size)); |
| return chunks; |
| } |
|
|
| function phpString(value) { |
| return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; |
| } |
| |
| function phpChunkArray(base64) { |
| return `[${chunkString(base64).map(phpString).join(",")}]`; |
| } |
| |
| async function listFilesRecursive(dir) { |
| try { |
| const entries = await fs.readdir(dir, { withFileTypes: true }); |
| const files = []; |
| for (const entry of entries) { |
| const full = path.join(dir, entry.name); |
| if (entry.isDirectory()) files.push(...await listFilesRecursive(full)); |
| else files.push(full); |
| } |
| return files; |
| } catch { |
| return []; |
| } |
| } |
| |
| function mimeForAssetFile(file, data) { |
| const ext = path.extname(file).toLowerCase(); |
| const fallback = mimeTypes[ext] || "application/octet-stream"; |
| if (fallback === "application/octet-stream") { |
| const sample = data.subarray(0, 256).toString("utf8").trimStart().toLowerCase(); |
| if (sample.startsWith("<!doctype html") || sample.startsWith("<html")) return "text/html; charset=utf-8"; |
| } |
| return fallback; |
| } |
| |
| function fixedProjectMenuHtml(config) { |
| const staticItems = (config.menuItems || []).map((item) => '<li><a href="' + escapeHtml(item.path) + '">' + escapeHtml(item.title) + '</a></li>').join(""); |
| const casinoChildren = (config.casinoPages || []).map((page) => '<li><a href="' + escapeHtml(page.path) + '">' + escapeHtml(page.title) + '</a></li>').join(""); |
| return staticItems + '<li class="aci-casino-accordion"><button class="aci-casino-toggle" type="button" aria-expanded="false">' + escapeHtml(config.casinoMenuLabel || "Casino") + '</button><ul class="aci-casino-submenu">' + casinoChildren + '</ul></li>'; |
| } |
| |
| function injectStaticCasinoAccordion(html) { |
| const css = '<style id="aci-casino-menu-style">.aci-casino-accordion{list-style:none}.aci-casino-toggle{width:100%;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;padding:0}.aci-casino-toggle:after{content:" +";float:right}.aci-casino-accordion.is-open>.aci-casino-toggle:after{content:" -"}.aci-casino-submenu{display:none;margin:0;padding-left:16px}.aci-casino-accordion.is-open>.aci-casino-submenu{display:block}</style>'; |
| const js = '<script id="aci-casino-menu-script">document.addEventListener("click",function(event){var toggle=event.target.closest(".aci-casino-toggle");if(!toggle)return;event.preventDefault();var item=toggle.closest(".aci-casino-accordion");var open=!item.classList.contains("is-open");item.classList.toggle("is-open",open);toggle.setAttribute("aria-expanded",open?"true":"false");});</script>'; |
| if (!html.includes('aci-casino-menu-style')) html = html.replace(/<\/head>/i, css + '</head>'); |
| if (!html.includes('aci-casino-menu-script')) html = html.replace(/<\/body>/i, js + '</body>'); |
| return html; |
| } |
| |
| function pruneStaticMenus(html, config) { |
| const menu = fixedProjectMenuHtml(config); |
| html = html.replace(/(<div class=["']menu["']>\s*<ul[^>]*>)[\s\S]*?(<\/ul>)/i, '$1' + menu + '$2'); |
| return injectStaticCasinoAccordion(html); |
| } |
| function normalizeStaticHtmlForTheme(html, config) { |
| html = html.replace(/<nav class=["']aci-recovered-nav["']>[\s\S]*?<\/nav>\s*/gi, ""); |
| html = pruneStaticMenus(html, config); |
| return html; |
| } |
| |
| async function buildEmbeddedThemeData(themeDir, manifest, config) { |
| const dataDir = path.join(themeDir, "data"); |
| const staticDir = path.join(dataDir, "static-html"); |
| const assetDir = path.join(dataDir, "archive-assets"); |
| const staticFiles = await listFilesRecursive(staticDir); |
| const assetFiles = await listFilesRecursive(assetDir); |
| const staticCases = []; |
| const assetCases = []; |
| |
| for (const file of staticFiles) { |
| const name = path.basename(file); |
| const html = normalizeStaticHtmlForTheme(await fs.readFile(file, "utf8"), config); |
| staticCases.push(` case ${phpString(name)}: return acwpb_embedded_decode(${phpChunkArray(Buffer.from(html, "utf8").toString("base64"))});`); |
| } |
| |
| // Gutenberg-контент для каждой страницы (для post_content при импорте) |
| const gutenbergCases = []; |
| for (const page of manifest.staticPages) { |
| if (page.gutenbergContent) { |
| gutenbergCases.push(` case ${phpString(page.file)}: return acwpb_embedded_decode(${phpChunkArray(Buffer.from(page.gutenbergContent, "utf8").toString("base64"))});`); |
| } |
| } |
| |
| for (const file of assetFiles) { |
| const name = path.basename(file); |
| const data = await fs.readFile(file); |
| const mime = mimeForAssetFile(name, data); |
| assetCases.push(` case ${phpString(name)}: return ['mime' => ${phpString(mime)}, 'body' => acwpb_embedded_decode(${phpChunkArray(data.toString("base64"))})];`); |
| } |
| |
| return { |
| manifestChunks: phpChunkArray(Buffer.from(JSON.stringify(manifest, null, 2), "utf8").toString("base64")), |
| staticCases: staticCases.join("\n"), |
| gutenbergCases: gutenbergCases.join("\n"), |
| assetCases: assetCases.join("\n") |
| }; |
| } |
| async function writePlugin(pluginDir, manifest) { |
| const php = `<?php |
| |
| |
| |
| |
| |
|
|
| if (!defined('ABSPATH')) { exit; } |
|
|
| define('ACI_PLUGIN_FILE', __FILE__); |
| define('ACI_PLUGIN_DIR', plugin_dir_path(__FILE__)); |
| define('ACI_PLUGIN_URL', plugin_dir_url(__FILE__)); |
|
|
| function aci_manifest() { |
| static $manifest = null; |
| if ($manifest === null) { |
| $file = ACI_PLUGIN_DIR . 'data/pages.json'; |
| if (!file_exists($file)) { |
| $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]]; |
| return $manifest; |
| } |
| $json = file_get_contents($file); |
| $manifest = json_decode($json, true); |
| if (!is_array($manifest)) { |
| $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]]; |
| } |
| if (!isset($manifest['staticPages']) || !is_array($manifest['staticPages'])) { |
| $manifest['staticPages'] = []; |
| } |
| if (!isset($manifest['casino']) || !is_array($manifest['casino'])) { |
| $manifest['casino'] = ['menuLabel' => 'Casino', 'pages' => []]; |
| } |
| if (!isset($manifest['casino']['pages']) || !is_array($manifest['casino']['pages'])) { |
| $manifest['casino']['pages'] = []; |
| } |
| if (empty($manifest['casino']['menuLabel'])) { |
| $manifest['casino']['menuLabel'] = 'Casino'; |
| } |
| } |
| return $manifest; |
| } |
|
|
| function aci_static_html($file) { |
| $path = ACI_PLUGIN_DIR . 'data/static-html/' . basename($file); |
| if (!file_exists($path)) { return ''; } |
| $html = file_get_contents($path); |
| $html = str_replace('{{ACI_ASSET_URL}}', esc_url(ACI_PLUGIN_URL . 'data/archive-assets'), $html); |
| $html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html); |
| return $html; |
| } |
|
|
| function aci_template_redirect() { |
| if (is_admin()) { return; } |
| $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); |
| $path = '/' . trim($path, '/') . '/'; |
| if ($path === '//') { $path = '/'; } |
| $manifest = aci_manifest(); |
| foreach ($manifest['staticPages'] as $page) { |
| if ($page['path'] === $path) { |
| status_header(200); |
| echo aci_static_html($page['file']); |
| exit; |
| } |
| } |
| } |
| add_action('template_redirect', 'aci_template_redirect', 0); |
|
|
| function aci_admin_menu() { |
| add_management_page('Archive Casino Importer', 'Archive Casino Importer', 'manage_options', 'archive-casino-importer', 'aci_admin_page'); |
| } |
| add_action('admin_menu', 'aci_admin_menu'); |
|
|
| function aci_admin_page() { |
| if (!current_user_can('manage_options')) { return; } |
| $message = ''; |
| if (isset($_POST['aci_import']) && check_admin_referer('aci_import')) { |
| $message = aci_run_import(); |
| } |
| $manifest = aci_manifest(); |
| echo '<div class="wrap"><h1>Archive Casino Importer</h1>'; |
| if ($message) { echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>'; } |
| if (!count($manifest['staticPages']) && !count($manifest['casino']['pages'])) { |
| echo '<div class="notice notice-error"><p>Import manifest is empty or missing. Rebuild and reinstall the importer ZIP.</p></div>'; |
| } |
| echo '<p>Static pages: ' . intval(count($manifest['staticPages'])) . '. Gutenberg Casino pages: ' . intval(count($manifest['casino']['pages'])) . '.</p>'; |
| echo '<form method="post">'; |
| wp_nonce_field('aci_import'); |
| submit_button('Import pages and menu', 'primary', 'aci_import'); |
| echo '</form></div>'; |
| } |
|
|
| function aci_run_import() { |
| $manifest = aci_manifest(); |
| if (!count($manifest['staticPages']) && !count($manifest['casino']['pages'])) { |
| return 'Import manifest is empty. Nothing was imported.'; |
| } |
| $menu_id = wp_create_nav_menu('Main Menu'); |
| if (is_wp_error($menu_id)) { |
| $menu = wp_get_nav_menu_object('Main Menu'); |
| $menu_id = $menu ? $menu->term_id : 0; |
| } |
|
|
| foreach ($manifest['staticPages'] as $page) { |
| $post_id = aci_upsert_page($page['title'], sanitize_title($page['title']), '<!-- Static archive page. Rendered by Archive Casino Importer. -->'); |
| update_post_meta($post_id, '_aci_static_file', $page['file']); |
| if ($page['path'] === '/') { |
| update_option('show_on_front', 'page'); |
| update_option('page_on_front', $post_id); |
| } |
| if ($menu_id) { |
| wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $page['title'], |
| 'menu-item-url' => home_url($page['path']), |
| 'menu-item-status' => 'publish' |
| ]); |
| } |
| } |
|
|
| $casino_root_id = 0; |
| foreach ($manifest['casino']['pages'] as $index => $page) { |
| $content = aci_casino_page_content($page['title'], $index); |
| $post_id = aci_upsert_page($page['title'], $page['slug'], $content); |
| if ($index === 0) { $casino_root_id = $post_id; } |
| if ($index > 0 && $casino_root_id && strpos($page['path'], '/casino/') === 0) { |
| wp_update_post(['ID' => $post_id, 'post_parent' => $casino_root_id]); |
| } |
| } |
|
|
| if ($menu_id) { |
| wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $manifest['casino']['menuLabel'], |
| 'menu-item-object' => 'page', |
| 'menu-item-object-id' => $casino_root_id, |
| 'menu-item-type' => 'post_type', |
| 'menu-item-status' => 'publish' |
| ]); |
| $locations = get_theme_mod('nav_menu_locations', []); |
| $locations['primary'] = $menu_id; |
| set_theme_mod('nav_menu_locations', $locations); |
| } |
|
|
| flush_rewrite_rules(); |
| return 'Import complete.'; |
| } |
|
|
| function aci_upsert_page($title, $slug, $content) { |
| $existing = get_page_by_path($slug); |
| $post = [ |
| 'post_title' => $title, |
| 'post_name' => $slug, |
| 'post_content' => $content, |
| 'post_status' => 'publish', |
| 'post_type' => 'page' |
| ]; |
| if ($existing) { |
| $post['ID'] = $existing->ID; |
| wp_update_post($post); |
| return $existing->ID; |
| } |
| return wp_insert_post($post); |
| } |
|
|
| function aci_casino_page_content($title, $index) { |
| $title = esc_html($title); |
| return '<!-- wp:group {"className":"casino-hero","layout":{"type":"constrained"}} --><div class="wp-block-group casino-hero"><!-- wp:heading {"level":1} --><h1>' . $title . '</h1><!-- /wp:heading --><!-- wp:paragraph --><p>Editable casino content section. Replace this starter copy with your article, review, bonuses, images, and editorial notes.</p><!-- /wp:paragraph --><!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Primary CTA</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:group --><!-- wp:columns {"className":"casino-layout"} --><div class="wp-block-columns casino-layout"><!-- wp:column --><div class="wp-block-column"><!-- wp:heading {"level":2} --><h2>Overview</h2><!-- /wp:heading --><!-- wp:paragraph --><p>Add article text, screenshots, comparison notes, and contextual links here.</p><!-- /wp:paragraph --><!-- wp:image --><figure class="wp-block-image"><img alt=""/></figure><!-- /wp:image --></div><!-- /wp:column --><!-- wp:column --><div class="wp-block-column"><!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Card</h3><!-- /wp:heading --><!-- wp:list --><ul><li>Bonus details</li><li>Payment notes</li><li>Editorial checks</li></ul><!-- /wp:list --></div><!-- /wp:group --></div><!-- /wp:column --></div><!-- /wp:columns --><!-- wp:heading {"level":2} --><h2>FAQ</h2><!-- /wp:heading --><!-- wp:details --><details class="wp-block-details"><summary>Question</summary><p>Answer text.</p></details><!-- /wp:details -->'; |
| } |
|
|
| function aci_register_patterns() { |
| if (!function_exists('register_block_pattern')) { return; } |
| if (function_exists('register_block_pattern_category')) { |
| register_block_pattern_category('archive-casino', ['label' => 'Archive Casino']); |
| } |
| register_block_pattern('archive-casino/review-card', [ |
| 'title' => 'Casino Review Card', |
| 'categories' => ['archive-casino'], |
| 'content' => '<!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Name</h3><!-- /wp:heading --><!-- wp:paragraph --><p>Short editable review summary.</p><!-- /wp:paragraph --><!-- wp:list --><ul><li>Pros item</li><li>Payment note</li><li>Bonus condition</li></ul><!-- /wp:list --></div><!-- /wp:group -->' |
| ]); |
| } |
| add_action('init', 'aci_register_patterns'); |
| `; |
| |
| await fs.writeFile(path.join(pluginDir, "archive-casino-importer.php"), php, "utf8"); |
| } |
| |
| async function writeTheme(themeDir, config, manifest) { |
| const embedded = await buildEmbeddedThemeData(themeDir, manifest, config); |
| const style = ` |
| |
| |
| |
| |
| |
|
|
| :root { |
| --aci-primary: ${manifest.design.primary}; |
| --aci-accent: ${manifest.design.accent}; |
| --aci-bg: ${manifest.design.background}; |
| --aci-text: ${manifest.design.text}; |
| --aci-font: ${manifest.design.font}; |
| } |
|
|
| body { |
| margin: 0; |
| background: var(--aci-bg); |
| color: var(--aci-text); |
| font-family: var(--aci-font); |
| line-height: 1.6; |
| } |
|
|
| a { color: var(--aci-primary); } |
| .site-header, .site-footer { |
| border-bottom: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent); |
| padding: 18px max(24px, calc((100vw - 1120px) / 2)); |
| } |
| .site-footer { border-top: 1px solid color-mix(in srgb, var(--aci-text) 15%, transparent); border-bottom: 0; margin-top: 48px; } |
| .site-brand { font-weight: 700; color: var(--aci-text); text-decoration: none; } |
| .site-nav ul { display: flex; flex-wrap: wrap; gap: 18px; list-style: none; margin: 14px 0 0; padding: 0; } |
| .site-nav a { text-decoration: none; font-weight: 600; } |
|
|
| |
| .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; } |
|
|
| |
| .entry-content, article.page { max-width: 940px; margin: 0 auto; padding: 24px 20px; } |
| .entry-content h1 { color: var(--aci-primary); font-size: 1.5rem; margin: 0 0 16px; } |
| .entry-content h2 { color: var(--aci-primary); margin: 28px 0 12px; } |
| .entry-content h3 { color: var(--aci-accent); margin: 20px 0 8px; } |
| .entry-content a { color: var(--aci-text); text-decoration: underline; } |
| .casino-hero { |
| background: color-mix(in srgb, var(--aci-primary) 12%, transparent); |
| border: 1px solid color-mix(in srgb, var(--aci-primary) 25%, transparent); |
| border-radius: 8px; |
| padding: 24px; |
| margin: 0 0 24px; |
| } |
| .casino-rating-card { |
| background: var(--aci-bg); |
| border: 1px solid color-mix(in srgb, var(--aci-primary) 25%, transparent); |
| border-radius: 6px; |
| padding: 20px; |
| margin: 0 0 24px; |
| } |
| .casino-stars { color: var(--aci-primary); font-size: 1.4em; letter-spacing: 3px; } |
| .casino-pros-cons { gap: 20px; margin: 16px 0; } |
| .casino-pros, .casino-cons { padding: 16px; border-radius: 4px; } |
| .casino-pros { background: #e8f5e9; border-left: 4px solid #058d28; } |
| .casino-cons { background: #fce4ec; border-left: 4px solid var(--aci-primary); } |
| .casino-pros h3 { color: #058d28; } |
| .casino-cons h3 { color: var(--aci-primary); } |
| .wp-block-button__link { |
| background: var(--aci-primary); |
| color: #fff; |
| border-radius: 0; |
| font-weight: bold; |
| border: none; |
| } |
| .wp-block-button__link:hover { background: var(--aci-accent); } |
| .entry-content table { width: 100%; border-collapse: collapse; margin: 12px 0; } |
| .entry-content th { background: color-mix(in srgb, var(--aci-primary) 20%, transparent); color: var(--aci-primary); padding: 10px; text-align: left; } |
| .entry-content td { padding: 10px; border-bottom: 1px solid color-mix(in srgb, var(--aci-primary) 15%, transparent); } |
| .entry-content figure img, |
| .entry-content .wp-block-image img { border: 3px solid color-mix(in srgb, var(--aci-primary) 25%, transparent); } |
| .wp-block-details { margin: 8px 0; border: 1px solid color-mix(in srgb, var(--aci-primary) 15%, transparent); border-radius: 4px; padding: 8px 14px; background: color-mix(in srgb, var(--aci-primary) 4%, transparent); } |
| .wp-block-details summary { font-weight: 600; color: var(--aci-accent); cursor: pointer; } |
| `; |
| |
| // Демо-контент Casino (на русском, в стиле сайта) — вставляется в PHP-шаблон. |
| // Экранируем обратные слеши и $ чтобы PHP получил литеральную строку. |
| const casinoContentPhp = casinoDemoContent("__TITLE__").replace(/\$/g, "\\\\$").replace(/__TITLE__/g, "' . $title . '"); |
| |
| const functions = `<?php |
| if (!defined('ABSPATH')) { exit; } |
|
|
| function acwpb_embedded_decode($chunks) { |
| return base64_decode(implode('', $chunks)); |
| } |
|
|
| function acwpb_theme_embedded_manifest() { |
| $json = acwpb_embedded_decode(${embedded.manifestChunks}); |
| $manifest = json_decode($json, true); |
| if (!is_array($manifest)) { |
| $manifest = ['staticPages' => [], 'casino' => ['menuLabel' => 'Casino', 'pages' => []]]; |
| } |
| return $manifest; |
| } |
|
|
| function acwpb_theme_embedded_static_html($file) { |
| switch (basename((string) $file)) { |
| ${embedded.staticCases} |
| default: return ''; |
| } |
| } |
|
|
| function acwpb_theme_embedded_gutenberg($file) { |
| switch (basename((string) $file)) { |
| ${embedded.gutenbergCases} |
| default: return ''; |
| } |
| } |
|
|
| function acwpb_theme_embedded_asset_payload($file) { |
| switch (basename((string) $file)) { |
| ${embedded.assetCases} |
| default: return null; |
| } |
| } |
|
|
| function acwpb_theme_manifest() { |
| static $manifest = null; |
| if ($manifest !== null) { return $manifest; } |
|
|
| $fallback = acwpb_theme_embedded_manifest(); |
| $file = get_template_directory() . '/data/pages.json'; |
| if (!file_exists($file)) { |
| $manifest = $fallback; |
| return $manifest; |
| } |
|
|
| $json = file_get_contents($file); |
| $manifest = json_decode($json, true); |
| if (!is_array($manifest)) { $manifest = $fallback; } |
| if (!isset($manifest['staticPages']) || !is_array($manifest['staticPages'])) { $manifest['staticPages'] = []; } |
| if (!isset($manifest['casino']) || !is_array($manifest['casino'])) { $manifest['casino'] = $fallback['casino']; } |
| if (!isset($manifest['casino']['pages']) || !is_array($manifest['casino']['pages'])) { $manifest['casino']['pages'] = []; } |
| if (empty($manifest['casino']['menuLabel'])) { $manifest['casino']['menuLabel'] = 'Casino'; } |
|
|
| return $manifest; |
| } |
|
|
| function acwpb_theme_setup() { |
| add_theme_support('title-tag'); |
| add_theme_support('post-thumbnails'); |
| add_theme_support('wp-block-styles'); |
| add_theme_support('editor-styles'); |
| register_nav_menus(['primary' => 'Primary Menu']); |
| add_editor_style('style.css'); |
| } |
| add_action('after_setup_theme', 'acwpb_theme_setup'); |
|
|
| function acwpb_theme_assets() { |
| wp_enqueue_style('acwpb-theme-style', get_stylesheet_uri(), [], '1.0.6'); |
|
|
| |
| |
| if (is_page() && !is_front_page()) { |
| $post_id = get_the_ID(); |
| $is_casino = get_post_meta($post_id, '_acwpb_casino_page', true); |
| if (!$is_casino) { |
| |
| $parent_id = wp_get_post_parent_id($post_id); |
| if ($parent_id) { |
| $parent_slug = get_page_uri($parent_id); |
| if (strpos($parent_slug, 'casino') !== false) { $is_casino = true; } |
| } |
| } |
| if ($is_casino) { |
| $manifest_assets = acwpb_theme_manifest(); |
| $da = $manifest_assets['designAssets'] ?? []; |
| $asset_base = esc_url(home_url('/acwpb-assets')); |
| $css_master = $da['cssMaster'] ?? ''; |
| $css_reset = $da['cssReset'] ?? ''; |
| $bg = $da['bgImage'] ?? ''; |
| $logo = $da['logo'] ?? ''; |
| if ($css_reset) { wp_enqueue_style('acwpb-reset', $asset_base . '/' . $css_reset, [], '1.0'); } |
| if ($css_master) { wp_enqueue_style('acwpb-master', $asset_base . '/' . $css_master, ['acwpb-reset'], '1.0'); } |
| |
| $override = ''; |
| if ($bg) { |
| $override .= 'body{background:url(' . $asset_base . '/' . $bg . ') repeat-x fixed top center !important;}'; |
| } |
| if ($logo) { |
| $override .= '.site-brand img, .logo img{content:url(' . $asset_base . '/' . $logo . ');}'; |
| } |
| if ($override && $css_master) { |
| wp_add_inline_style('acwpb-master', $override); |
| } elseif ($override) { |
| wp_add_inline_style('acwpb-theme-style', $override); |
| } |
| } |
| } |
| } |
| add_action('wp_enqueue_scripts', 'acwpb_theme_assets'); |
| add_action('enqueue_block_editor_assets', 'acwpb_theme_assets'); |
|
|
| function acwpb_theme_static_html($file) { |
| $path = get_template_directory() . '/data/static-html/' . basename($file); |
| if (file_exists($path)) { |
| $html = file_get_contents($path); |
| } else { |
| $html = acwpb_theme_embedded_static_html($file); |
| } |
| $html = str_replace('{{ACI_ASSET_URL}}', esc_url(home_url('/acwpb-assets')), $html); |
| $html = str_replace('{{ACI_SITE_URL}}', esc_url(home_url('/')), $html); |
| return $html; |
| } |
|
|
| function acwpb_theme_static_router() { |
| if (is_admin()) { return; } |
| $raw_path = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '/'; |
| $raw_path = '/' . ltrim(rawurldecode((string) $raw_path), '/'); |
| if ($raw_path === '/') { |
| wp_redirect(home_url('/cz/'), 301); |
| exit; |
| } |
| if (preg_match('#^/acwpb-assets/([^/]+)$#', $raw_path, $match)) { |
| $payload = acwpb_theme_embedded_asset_payload($match[1]); |
| if (!$payload) { status_header(404); exit; } |
| status_header(200); |
| header('Content-Type: ' . $payload['mime']); |
| |
| |
| if (strpos($payload['mime'], 'text/html') === false) { |
| header('Content-Disposition: inline; filename="' . basename($match[1]) . '"'); |
| } else { |
| header('Content-Disposition: inline'); |
| } |
| header('Cache-Control: public, max-age=31536000, immutable'); |
| header('Content-Length: ' . strlen($payload['body'])); |
| echo $payload['body']; |
| exit; |
| } |
| $manifest = acwpb_theme_manifest(); |
| $path = trailingslashit($raw_path); |
|
|
| |
| |
| |
| } |
| add_action('template_redirect', 'acwpb_theme_static_router', 1); |
|
|
| |
| |
| |
| |
| function acwpb_theme_patch_static_menu($html) { |
| $manifest = acwpb_theme_manifest(); |
|
|
| $items_html = ''; |
| foreach (($manifest['menuItems'] ?? []) as $mi) { |
| $title = esc_html($mi['title'] ?? ''); |
| $mpath = esc_url(home_url($mi['path'] ?? '#')); |
| $items_html .= '<li><a href="' . $mpath . '">' . $title . '</a></li>'; |
| } |
|
|
| |
| $casino_label = esc_html($manifest['casino']['menuLabel'] ?? 'Casino'); |
| $submenu = ''; |
| foreach (($manifest['casino']['pages'] ?? []) as $cp) { |
| $ctitle = esc_html($cp['title'] ?? 'Casino'); |
| $cpath = esc_url(home_url($cp['path'] ?? '#')); |
| $submenu .= '<li><a href="' . $cpath . '">' . $ctitle . '</a></li>'; |
| } |
| $items_html .= '<li class="menu-item-has-children"><a href="#" onclick="return false;">' . $casino_label . ' ▾</a><ul class="sub-menu">' . $submenu . '</ul></li>'; |
|
|
| |
| if (preg_match('#(<div[^>]*class="menu"[^>]*>\\s*<ul[^>]*>)[\\s\\S]*?(</ul>)#i', $html)) { |
| $html = preg_replace('#(<div[^>]*class="menu"[^>]*>\\s*<ul[^>]*>)[\\s\\S]*?(</ul>)#i', |
| '$1' . $items_html . '$2', $html, 1); |
| } elseif (preg_match('#(<ul[^>]*>)[\\s\\S]*?O restauraci[\\s\\S]*?(</ul>)#i', $html)) { |
| $html = preg_replace('#(<ul[^>]*>)[\\s\\S]*?O restauraci[\\s\\S]*?(</ul>)#i', |
| '$1' . $items_html . '$2', $html, 1); |
| } |
|
|
| |
| if (strpos($html, 'aci-accordion-style') === false) { |
| $css = '<style id="aci-accordion-style">' . |
| '.menu ul, .menu li { list-style: none; }' . |
| '.menu-item-has-children { position: relative; }' . |
| '.menu-item-has-children > a { cursor: pointer; }' . |
| '.menu .sub-menu { display: none; position: absolute; left: 0; top: 100%; background: #fff; border: 1px solid #e7d2a0; border-radius: 6px; padding: 6px 0; margin: 2px 0 0; min-width: 200px; list-style: none; z-index: 1000; box-shadow: 0 6px 18px rgba(0,0,0,0.12); }' . |
| '.menu-item-has-children:hover > .sub-menu, .menu-item-has-children.is-open > .sub-menu { display: block; }' . |
| '.menu .sub-menu li { white-space: nowrap; }' . |
| '.menu .sub-menu a { display: block; padding: 8px 16px; }' . |
| '.menu .sub-menu a:hover { background: #f7eee6; }' . |
| '</style>'; |
| $html = preg_replace('#</head>#i', $css . '</head>', $html, 1); |
| } |
| return $html; |
| } |
|
|
| function acwpb_theme_activate() { |
| $manifest = acwpb_theme_manifest(); |
|
|
| |
| |
| $import_hash = md5(json_encode($manifest)); |
| if (get_option('acwpb_theme_imported') === $import_hash) { |
| return; |
| } |
|
|
| $menu_id = acwpb_theme_menu_id(); |
| acwpb_theme_clear_menu($menu_id); |
| $casino_root_id = 0; |
| $static_root_id = 0; |
|
|
| |
| |
| $casino_root_id = acwpb_theme_upsert_page('Casino', 'casino', '<!-- wp:paragraph --><p>Раздел Casino.</p><!-- /wp:paragraph -->'); |
| if (is_wp_error($casino_root_id)) { $casino_root_id = 0; } |
|
|
| foreach ($manifest['staticPages'] as $page) { |
| if (empty($page['title']) || empty($page['path']) || empty($page['file'])) { continue; } |
| $slug = acwpb_theme_slug_from_path($page['path']); |
| |
| |
| $content = acwpb_theme_embedded_gutenberg($page['file']); |
| if (empty($content)) { |
| |
| $raw_html = acwpb_theme_static_html($page['file']); |
| $raw_html = str_replace('{{ACI_ASSET_URL}}', esc_url(home_url('/acwpb-assets')), $raw_html); |
| $content = '<!-- wp:html -->' . $raw_html . '<!-- /wp:html -->'; |
| } else { |
| |
| $content = str_replace('{{ACI_ASSET_URL}}', esc_url(home_url('/acwpb-assets')), $content); |
| } |
| $post_id = acwpb_theme_upsert_page($page['title'], $slug, $content); |
| if (is_wp_error($post_id) || !$post_id) { continue; } |
| update_post_meta($post_id, '_acwpb_static_path', $page['path']); |
| update_post_meta($post_id, '_acwpb_static_file', $page['file']); |
| if ($page['path'] === '/' || $page['path'] === '/cz/') { |
| $static_root_id = $post_id; |
| } |
| } |
|
|
| foreach ($manifest['casino']['pages'] as $index => $page) { |
| if (empty($page['title']) || empty($page['slug'])) { continue; } |
| $post_id = acwpb_theme_upsert_page($page['title'], $page['slug'], acwpb_theme_casino_content($page['title'])); |
| if (is_wp_error($post_id) || !$post_id) { continue; } |
| |
| if ($casino_root_id) { |
| wp_update_post(['ID' => $post_id, 'post_parent' => $casino_root_id]); |
| } |
| } |
|
|
| foreach (acwpb_theme_primary_menu_items() as $item) { |
| acwpb_theme_add_menu_url($menu_id, $item['title'], home_url($item['path'])); |
| } |
|
|
| |
| |
| $casino_pages_menu = $manifest['casino']['pages'] ?? []; |
| $casino_label_menu = $manifest['casino']['menuLabel'] ?? 'Casino'; |
| $parent_db = wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $casino_label_menu, |
| 'menu-item-url' => '#', |
| 'menu-item-type' => 'custom', |
| 'menu-item-status' => 'publish' |
| ]); |
| if (!empty($parent_db)) { |
| foreach ($casino_pages_menu as $cp) { |
| $child_post = get_page_by_path(sanitize_title($cp['slug'] ?? ''), OBJECT, 'page'); |
| if ($child_post) { |
| wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $cp['title'] ?? 'Casino', |
| 'menu-item-object' => 'page', |
| 'menu-item-object-id' => $child_post->ID, |
| 'menu-item-type' => 'post_type', |
| 'menu-item-parent-id' => $parent_db, |
| 'menu-item-status' => 'publish' |
| ]); |
| } |
| } |
| } |
|
|
| $locations = get_theme_mod('nav_menu_locations', []); |
| $locations['primary'] = $menu_id; |
| set_theme_mod('nav_menu_locations', $locations); |
| if ($static_root_id) { |
| update_option('show_on_front', 'page'); |
| update_option('page_on_front', $static_root_id); |
| } |
| update_option('acwpb_theme_imported', md5(json_encode($manifest))); |
| flush_rewrite_rules(); |
| } |
| add_action('after_switch_theme', 'acwpb_theme_activate'); |
|
|
| |
| |
| |
| |
|
|
| function acwpb_theme_admin_auto_import() { |
| |
| } |
| |
| |
|
|
| function acwpb_theme_admin_menu() { |
| add_theme_page('Recovered Site Import', 'Recovered Site Import', 'manage_options', 'acwpb-theme-import', 'acwpb_theme_import_page'); |
| } |
| add_action('admin_menu', 'acwpb_theme_admin_menu'); |
|
|
| function acwpb_theme_import_page() { |
| if (!current_user_can('manage_options')) { return; } |
| $manifest = acwpb_theme_manifest(); |
| $message = ''; |
| if (isset($_POST['acwpb_theme_import']) && check_admin_referer('acwpb_theme_import')) { |
| acwpb_theme_activate(); |
| $message = 'Import completed.'; |
| } |
| $counts = acwpb_theme_import_counts($manifest); |
| echo '<div class="wrap"><h1>Recovered Site Import</h1>'; |
| if ($message) { echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>'; } |
| echo '<p>Expected static pages: ' . intval(count($manifest['staticPages'])) . '. Created: ' . intval($counts['static']) . '.</p>'; |
| echo '<p>Expected Casino pages: ' . intval(count($manifest['casino']['pages'])) . '. Created: ' . intval($counts['casino']) . '.</p>'; |
| echo '<form method="post">'; |
| wp_nonce_field('acwpb_theme_import'); |
| submit_button('Run import now', 'primary', 'acwpb_theme_import'); |
| echo '</form></div>'; |
| } |
|
|
| function acwpb_theme_import_complete($manifest) { |
| $counts = acwpb_theme_import_counts($manifest); |
| return $counts['static'] >= count($manifest['staticPages']) && $counts['casino'] >= count($manifest['casino']['pages']); |
| } |
|
|
| function acwpb_theme_import_counts($manifest) { |
| $static_count = 0; |
| foreach ($manifest['staticPages'] as $page) { |
| if (empty($page['path'])) { continue; } |
| if (get_page_by_path(acwpb_theme_slug_from_path($page['path']))) { $static_count++; } |
| } |
| $casino_count = 0; |
| foreach ($manifest['casino']['pages'] as $page) { |
| if (empty($page['slug'])) { continue; } |
| if (get_page_by_path($page['slug'])) { $casino_count++; } |
| } |
| return ['static' => $static_count, 'casino' => $casino_count]; |
| } |
|
|
| function acwpb_theme_slug_from_path($path) { |
| $slug = trim($path, '/'); |
| if ($slug === '') { return 'home'; } |
| return str_replace('/', '-', $slug); |
| } |
|
|
| function acwpb_theme_primary_menu_items() { |
| |
| |
| $manifest = acwpb_theme_manifest(); |
| $items = []; |
| if (!empty($manifest['menuItems']) && is_array($manifest['menuItems'])) { |
| foreach ($manifest['menuItems'] as $item) { |
| if (!empty($item['title']) && !empty($item['path'])) { |
| $items[] = ['title' => $item['title'], 'path' => $item['path']]; |
| } |
| } |
| } |
| |
| if (empty($items)) { |
| $items = [ |
| ['title' => 'O restauraci', 'path' => '/cz/1-O-restauraci/'], |
| ['title' => 'Polední menu', 'path' => '/cz/9-Poledni-menu/'], |
| ['title' => 'Fotogalerie', 'path' => '/cz/15-Fotogalerie/'], |
| ['title' => 'Kontakty', 'path' => '/cz/17-Kontakty/'], |
| ]; |
| } |
| return $items; |
| } |
|
|
| function acwpb_theme_clear_menu($menu_id) { |
| if (!$menu_id) { return; } |
| $items = wp_get_nav_menu_items($menu_id); |
| if (!is_array($items)) { return; } |
| foreach ($items as $item) { |
| if (!empty($item->ID)) { wp_delete_post($item->ID, true); } |
| } |
| } |
|
|
| function acwpb_theme_menu_id() { |
| $menu = wp_get_nav_menu_object('Main Menu'); |
| if ($menu) { return $menu->term_id; } |
| $created = wp_create_nav_menu('Main Menu'); |
| return is_wp_error($created) ? 0 : $created; |
| } |
|
|
| function acwpb_theme_add_menu_url($menu_id, $title, $url) { |
| if (!$menu_id || acwpb_theme_menu_has_url($menu_id, $url)) { return; } |
| wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $title, |
| 'menu-item-url' => $url, |
| 'menu-item-status' => 'publish' |
| ]); |
| } |
|
|
| function acwpb_theme_add_menu_page($menu_id, $title, $post_id) { |
| if (!$menu_id || !$post_id) { return 0; } |
| |
| $existing = acwpb_theme_find_menu_item_by_object($menu_id, $post_id, 0); |
| if ($existing) { return $existing; } |
| $db_id = wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $title, |
| 'menu-item-object' => 'page', |
| 'menu-item-object-id' => $post_id, |
| 'menu-item-type' => 'post_type', |
| 'menu-item-status' => 'publish' |
| ]); |
| return $db_id ? $db_id : 0; |
| } |
|
|
| function acwpb_theme_add_menu_page_child($menu_id, $title, $post_id, $parent_db_id) { |
| if (!$menu_id || !$post_id || !$parent_db_id) { return; } |
| if (acwpb_theme_find_menu_item_by_object($menu_id, $post_id, $parent_db_id)) { return; } |
| wp_update_nav_menu_item($menu_id, 0, [ |
| 'menu-item-title' => $title, |
| 'menu-item-object' => 'page', |
| 'menu-item-object-id' => $post_id, |
| 'menu-item-type' => 'post_type', |
| 'menu-item-parent-id' => $parent_db_id, |
| 'menu-item-status' => 'publish' |
| ]); |
| } |
|
|
| function acwpb_theme_find_menu_item_by_object($menu_id, $object_id, $parent_db_id = 0) { |
| $items = wp_get_nav_menu_items($menu_id); |
| if (!is_array($items)) { return 0; } |
| foreach ($items as $item) { |
| if (intval($item->object_id) === intval($object_id) && intval($item->menu_item_parent) === intval($parent_db_id)) { |
| return $item->db_id; |
| } |
| } |
| return 0; |
| } |
|
|
| function acwpb_theme_menu_has_url($menu_id, $url) { |
| $items = wp_get_nav_menu_items($menu_id); |
| if (!is_array($items)) { return false; } |
| foreach ($items as $item) { |
| if (!empty($item->url) && untrailingslashit($item->url) === untrailingslashit($url)) { return true; } |
| } |
| return false; |
| } |
|
|
| function acwpb_theme_menu_has_object($menu_id, $post_id) { |
| $items = wp_get_nav_menu_items($menu_id); |
| if (!is_array($items)) { return false; } |
| foreach ($items as $item) { |
| if (intval($item->object_id) === intval($post_id)) { return true; } |
| } |
| return false; |
| } |
|
|
| function acwpb_theme_upsert_page($title, $slug, $content) { |
| $slug = sanitize_title($slug); |
| |
| $existing = get_page_by_path($slug, OBJECT, 'page'); |
| if (!$existing) { |
| |
| $existing = get_posts([ |
| 'post_type' => 'page', |
| 'post_status' => 'any', |
| 'name' => $slug, |
| 'numberposts' => 1, |
| ]); |
| $existing = is_array($existing) && count($existing) ? $existing[0] : null; |
| } |
| $post = [ |
| 'post_title' => $title, |
| 'post_name' => $slug, |
| 'post_content' => $content, |
| 'post_status' => 'publish', |
| 'post_type' => 'page' |
| ]; |
| if ($existing) { |
| $post['ID'] = $existing->ID; |
| return wp_update_post($post, true); |
| } |
| return wp_insert_post($post, true); |
| } |
|
|
| function acwpb_theme_casino_content($title) { |
| $title = esc_html($title); |
| return '${casinoContentPhp}'; |
| } |
|
|
| function acwpb_theme_register_patterns() { |
| if (!function_exists('register_block_pattern')) { return; } |
| if (function_exists('register_block_pattern_category')) { |
| register_block_pattern_category('archive-casino', ['label' => 'Archive Casino']); |
| } |
| register_block_pattern('archive-casino/review-card', [ |
| 'title' => 'Casino Review Card', |
| 'categories' => ['archive-casino'], |
| 'content' => '<!-- wp:group {"className":"casino-card"} --><div class="wp-block-group casino-card"><!-- wp:heading {"level":3} --><h3>Casino Name</h3><!-- /wp:heading --><!-- wp:paragraph --><p>Short editable review summary.</p><!-- /wp:paragraph --><!-- wp:list --><ul><li>Pros item</li><li>Payment note</li><li>Bonus condition</li></ul><!-- /wp:list --></div><!-- /wp:group -->' |
| ]); |
| } |
| add_action('init', 'acwpb_theme_register_patterns'); |
| `; |
| |
| const header = `<!doctype html> |
| <html <?php language_attributes(); ?>> |
| <head> |
| <meta charset="<?php bloginfo('charset'); ?>"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <?php wp_head(); ?> |
| </head> |
| <body <?php body_class(); ?>> |
| <?php wp_body_open(); ?> |
| <header class="site-header"> |
| <a class="site-brand" href="<?php echo esc_url(home_url('/')); ?>"><?php bloginfo('name'); ?></a> |
| <nav class="site-nav" aria-label="Primary navigation"> |
| <?php wp_nav_menu(['theme_location' => 'primary', 'container' => false, 'fallback_cb' => false]); ?> |
| </nav> |
| </header> |
| <main class="site-main"> |
| `; |
|
|
| const footer = `</main> |
| <footer class="site-footer"> |
| <small>© <?php echo esc_html(date('Y')); ?> <?php bloginfo('name'); ?></small> |
| </footer> |
| <?php wp_footer(); ?> |
| </body> |
| </html> |
| `; |
|
|
| const page = `<?php get_header(); ?> |
| <?php while (have_posts()) : the_post(); ?> |
| <article <?php post_class(); ?>> |
| <?php the_content(); ?> |
| </article> |
| <?php endwhile; ?> |
| <?php get_footer(); ?> |
| `; |
|
|
| await fs.writeFile(path.join(themeDir, "style.css"), style, "utf8"); |
| await fs.writeFile(path.join(themeDir, "functions.php"), functions, "utf8"); |
| await fs.writeFile(path.join(themeDir, "header.php"), header, "utf8"); |
| await fs.writeFile(path.join(themeDir, "footer.php"), footer, "utf8"); |
| await fs.writeFile(path.join(themeDir, "page.php"), page, "utf8"); |
| await fs.writeFile(path.join(themeDir, "index.php"), page, "utf8"); |
| } |
|
|
| // --------------------------------------------------------------------------- |
| // Генерация демо-контента Casino-страницы (на русском, в стиле сайта). |
| // Пользователь может удалить и заменить своими статьями. |
| // --------------------------------------------------------------------------- |
| function casinoDemoContent(title) { |
| const t = escapeHtml(title); |
| return '<!-- wp:group {"className":"casino-hero","layout":{"type":"constrained"}} -->' + |
| '<div class="wp-block-group casino-hero">' + |
| '<!-- wp:heading {"level":1} --><h1>' + t + '</h1><!-- /wp:heading -->' + |
| '<!-- wp:paragraph --><p>Редактируемая страница Casino. Здесь размещается статья с обзором, рейтингом, бонусами и изображениями. Стиль адаптирован под дизайн сайта. Замените этот демо-контент своим.</p><!-- /wp:paragraph -->' + |
| '<!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Играть →</a></div><!-- /wp:button --></div><!-- /wp:buttons -->' + |
| '</div><!-- /wp:group -->' + |
| '<!-- wp:group {"className":"casino-rating-card"} --><div class="wp-block-group casino-rating-card">' + |
| '<!-- wp:heading {"level":2} --><h2>Рейтинг</h2><!-- /wp:heading -->' + |
| '<!-- wp:paragraph {"className":"casino-stars"} --><p class="casino-stars">★★★★★</p><!-- /wp:paragraph -->' + |
| '<!-- wp:columns {"className":"casino-pros-cons"} --><div class="wp-block-columns casino-pros-cons">' + |
| '<!-- wp:column {"className":"casino-pros"} --><div class="wp-block-column casino-pros">' + |
| '<!-- wp:heading {"level":3} --><h3>Плюсы</h3><!-- /wp:heading -->' + |
| '<!-- wp:list --><ul><li>Лицензия Curaçao</li><li>Быстрый вывод</li><li>Мобильная версия</li></ul><!-- /wp:list -->' + |
| '</div><!-- /wp:column -->' + |
| '<!-- wp:column {"className":"casino-cons"} --><div class="wp-block-column casino-cons">' + |
| '<!-- wp:heading {"level":3} --><h3>Минусы</h3><!-- /wp:heading -->' + |
| '<!-- wp:list --><ul><li>Высокий вейджер</li><li>Нет поддержки на чешском</li></ul><!-- /wp:list -->' + |
| '</div><!-- /wp:column --></div><!-- /wp:columns -->' + |
| '</div><!-- /wp:group -->' + |
| '<!-- wp:heading {"level":2} --><h2>Обзор</h2><!-- /wp:heading -->' + |
| '<!-- wp:paragraph --><p>Описание казино: ассортимент игр, провайдеры, лицензия, методы оплаты и вывода. Текст редактируется в Gutenberg — кликни на блок, чтобы изменить.</p><!-- /wp:paragraph -->' + |
| '<!-- wp:image --><figure class="wp-block-image"><img alt="Скриншот казино"/></figure><!-- /wp:image -->' + |
| '<!-- wp:heading {"level":2} --><h2>Бонусы</h2><!-- /wp:heading -->' + |
| '<!-- wp:table --><figure class="wp-block-table"><table><thead><tr><th>Бонус</th><th>Сумма</th><th>Вейджер</th></tr></thead>' + |
| '<tbody><tr><td>Приветственный</td><td>100% до $500</td><td>x35</td></tr>' + |
| '<tr><td>Без депозита</td><td>$20</td><td>x50</td></tr>' + |
| '<tr><td>Фриспины</td><td>200 FS</td><td>x40</td></tr></tbody></table></figure><!-- /wp:table -->' + |
| '<!-- wp:heading {"level":2} --><h2>FAQ</h2><!-- /wp:heading -->' + |
| '<!-- wp:details --><details class="wp-block-details"><summary>Лицензировано ли казино?</summary><p>Да, лицензия Curaçao eGaming.</p></details><!-- /wp:details -->' + |
| '<!-- wp:details --><details class="wp-block-details"><summary>Какие методы вывода доступны?</summary><p>Банковские карты, Neteller, Skrill, криптовалюты.</p></details><!-- /wp:details -->'; |
| } |
|
|
| // =========================================================================== |
| // ФАЗА ОЧИСТКИ: авто-удаление битых (404) и внешних ссылок |
| // =========================================================================== |
|
|
| /** |
| * Очистка HTML от битых ссылок (404) и внешних ссылок на чужие домены. |
| * - <a href="чужой_домен">текст</a> → снимается обёртка <a>, остаётся текст |
| * - <img src="чужой_домен"> → удаляется полностью |
| * - <iframe src="чужой_домен"> → удаляется полностью |
| * - 404-е ссылки — то же самое |
| * Возвращает { cleanedHtml, removed: [{type, url, reason, action}] }. |
| */ |
| async function cleanExternalLinks(html, sourceDomain, progressLogs) { |
| const removed = []; |
| const sourceHost = sourceDomain.replace(/^www\./, "").toLowerCase(); |
|
|
| // Собрать все href/src с http(s) |
| const urlPattern = /\b(href|src)=["'](https?:\/\/[^"']+)["']/gi; |
| const matches = [...html.matchAll(urlPattern)]; |
| const toCheck = []; |
| const seen = new Set(); |
|
|
| for (const m of matches) { |
| const url = m[2]; |
| if (seen.has(url)) continue; |
| seen.add(url); |
| if (url.includes("web.archive.org")) continue; |
| toCheck.push({ url, attr: m[1] }); |
| } |
|
|
| // Проверить доступность и домен (батчами по 8) |
| const broken = new Set(); |
| const external = new Set(); |
|
|
| if (progressLogs) progressLogs.push(`[Clean] проверяю ${toCheck.length} ссылок...`); |
| const BATCH = 8; |
| for (let i = 0; i < toCheck.length; i += BATCH) { |
| const batch = toCheck.slice(i, i + BATCH); |
| const results = await Promise.allSettled(batch.map(async (item) => { |
| try { |
| const host = new URL(item.url).hostname.replace(/^www\./, "").toLowerCase(); |
| // внешний домен? |
| if (host !== sourceHost && !host.endsWith("." + sourceHost)) { |
| return { ...item, status: 0, external: true }; |
| } |
| // внутренний — проверяем HEAD |
| const controller = new AbortController(); |
| const timer = setTimeout(() => controller.abort(), 6000); |
| const r = await fetch(item.url, { method: "HEAD", signal: controller.signal, redirect: "follow" }); |
| clearTimeout(timer); |
| return { ...item, status: r.status, external: false }; |
| } catch (e) { |
| return { ...item, status: 0, external: true, unreachable: true }; |
| } |
| })); |
| for (const r of results) { |
| if (r.status !== "fulfilled") continue; |
| const val = r.value; |
| if (val.external) { |
| external.add(val.url); |
| const host = safeHostname(val.url); |
| removed.push({ url: val.url, reason: val.unreachable ? "недоступен" : "чужой домен", action: "удалён" }); |
| } else if (val.status === 404) { |
| broken.add(val.url); |
| removed.push({ url: val.url, reason: "404", action: "удалён" }); |
| } else if (val.status === 0) { |
| external.add(val.url); |
| removed.push({ url: val.url, reason: "timeout/недоступен", action: "удалён" }); |
| } |
| } |
| } |
| const toRemove = new Set([...broken, ...external]); |
|
|
| if (toRemove.size === 0) { |
| if (progressLogs) progressLogs.push(`[Clean] битых и внешних ссылок не найдено`); |
| return { cleanedHtml: html, removed: [] }; |
| } |
|
|
| // Применить удаления через DOM-подобный разбор |
| let cleaned = html; |
|
|
| // 1. Удалить <iframe src="битый"> полностью |
| cleaned = cleaned.replace(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*>[\s\S]*?<\/iframe>/gi, (whole, src) => { |
| if (toRemove.has(src)) { return ""; } |
| return whole; |
| }); |
| // self-closing iframe |
| cleaned = cleaned.replace(/<iframe\b[^>]*\bsrc=["']([^"']+)["'][^>]*\/?>/gi, (whole, src) => { |
| if (toRemove.has(src)) { return ""; } |
| return whole; |
| }); |
|
|
| // 2. Удалить <img src="битый"> полностью |
| cleaned = cleaned.replace(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*\/?>/gi, (whole, src) => { |
| if (toRemove.has(src)) { return ""; } |
| return whole; |
| }); |
|
|
| // 3. Для <a href="битый"> — снять обёртку, оставить содержимое (текст) |
| cleaned = cleaned.replace(/<a\b([^>]*)\bhref=["']([^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (whole, pre, href, post, inner) => { |
| if (toRemove.has(href)) { return inner; } |
| return whole; |
| }); |
|
|
| if (progressLogs) { |
| progressLogs.push(`[Clean] удалено ссылок: ${toRemove.size} (битых: ${broken.size}, внешних: ${external.size})`); |
| for (const r of removed.slice(0, 15)) { |
| progressLogs.push(` - ${r.url.slice(0, 70)} (${r.reason})`); |
| } |
| } |
|
|
| return { cleanedHtml: cleaned, removed }; |
| } |
|
|
| function safeHostname(url) { |
| try { return new URL(url).hostname; } catch { return "?"; } |
| } |
|
|
| // =========================================================================== |
| // ФАЗА РАЗБОРКИ: splitPageHtml — разбор архивного HTML на части |
| // =========================================================================== |
|
|
| /** |
| * Разобрать полный архивный HTML на 4 части: |
| * headAssets — <link>/<script> из <head> (для wp_enqueue) |
| * shellHtml — шапка, слайдер, меню (до контентной зоны) → header.php |
| * contentHtml — КОНТЕНТ страницы → Gutenberg-блоки |
| * footerHtml — футер (после контентной зоны) → footer.php |
| * |
| * Эвристика поиска контентной зоны: |
| * 1) классы: insert_page_right, content, main-content, article, entry-content |
| * 2) элемент с наибольшим числом <p>/<h1>-<h6>/<img> |
| */ |
| function splitPageHtml(html, contentSelector) { |
| let bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i); |
| const body = bodyMatch ? bodyMatch[1] : html; |
|
|
| let headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i); |
| const headAssets = headMatch ? extractHeadAssets(headMatch[1]) : { styles: [], scripts: [] }; |
|
|
| // поиск контентной зоны |
| let contentStart = -1, contentEnd = -1, contentOpenTag = "", contentCloseCount = 0; |
|
|
| const selectors = contentSelector |
| ? [contentSelector] |
| : ["insert_page_right", "content", "main-content", "main_content", |
| "article", "entry-content", "post-content", "post_content", |
| "page-content", "content-area", "site-main"]; |
|
|
| for (const cls of selectors) { |
| const re = new RegExp(`<div[^>]*class=["'][^"']*\\b${cls}\\b[^"']*["'][^>]*>`, "i"); |
| const m = body.match(re); |
| if (m) { |
| contentStart = body.indexOf(m[0]); |
| contentOpenTag = m[0]; |
| break; |
| } |
| } |
|
|
| // если не найдено по классам — fallback: найти <h1> и взять от него |
| if (contentStart === -1) { |
| const h1 = body.match(/<h[12][^>]*>/i); |
| if (h1) { |
| contentStart = body.indexOf(h1[0]); |
| contentOpenTag = ""; |
| } |
| } |
|
|
| // если совсем ничего — весь body |
| if (contentStart === -1) { |
| return { |
| headAssets, |
| shellHtml: "", |
| contentHtml: body, |
| footerHtml: "", |
| contentSelector: "(full body)" |
| }; |
| } |
|
|
| // найти конец контентной зоны: баланс div-ов от contentStart |
| if (contentOpenTag) { |
| // найти закрывающий тег для открывающего contentOpenTag |
| const afterStart = contentStart + contentOpenTag.length; |
| let depth = 1; |
| const tagRe = /<\/?div\b[^>]*>/gi; |
| tagRe.lastIndex = afterStart; |
| let m2; |
| while ((m2 = tagRe.exec(body)) !== null) { |
| if (m2[0].startsWith("</")) { |
| depth--; |
| if (depth === 0) { |
| contentEnd = m2.index + m2[0].length; |
| break; |
| } |
| } else { |
| depth++; |
| } |
| } |
| if (contentEnd === -1) contentEnd = body.length; |
| const contentFull = body.slice(contentStart, contentEnd); |
| // вытащить innerHTML (между открывающим и закрывающим тегом) |
| const contentHtml = contentFull.slice(contentOpenTag.length, contentFull.length - 6); // -"</div>" |
| return { |
| headAssets, |
| shellHtml: body.slice(0, contentStart), |
| contentHtml, |
| footerHtml: body.slice(contentEnd), |
| contentSelector: contentOpenTag.match(/class=["']([^"']*)["']/i)?.[1] || "(detected)" |
| }; |
| } else { |
| // без обёртки (fallback на h1) — контент до конца body |
| return { |
| headAssets, |
| shellHtml: body.slice(0, contentStart), |
| contentHtml: body.slice(contentStart), |
| footerHtml: "", |
| contentSelector: "(h1 fallback)" |
| }; |
| } |
| } |
|
|
| function extractHeadAssets(headHtml) { |
| const styles = []; |
| const scripts = []; |
| for (const m of headHtml.matchAll(/<link[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*>/gi)) { |
| styles.push(m[1]); |
| } |
| for (const m of headHtml.matchAll(/<script[^>]*\bsrc=["']([^"']+)["'][^>]*><\/script>/gi)) { |
| scripts.push(m[1]); |
| } |
| return { styles, scripts }; |
| } |
|
|
| // =========================================================================== |
| // ФАЗА КОНВЕРТАЦИИ: htmlToGutenberg — HTML-фрагмент → Gutenberg-блоки |
| // =========================================================================== |
|
|
| /** |
| * Конвертировать контентную зону в ОДИН Custom HTML блок. |
| * Весь контент (заголовки, абзацы, таблицы, картинки) идёт внутри |
| * <!-- wp:html -->...<!-- /wp:html --> — это не ломается при редактировании |
| * в WordPress HTML-редакторе. wp_kses_post не вырезает содержимое wp:html. |
| * |
| * Очистка: декодирование сущностей, удаление устаревших атрибутов, |
| * нормализация пробелов. Без разбиения на блоки — единый HTML-фрагмент. |
| */ |
| function htmlToGutenberg(html) { |
| // декодировать сущности и очистить |
| html = sanitizeForGutenberg(html); |
| if (!html.trim()) return ""; |
| // Один Custom HTML блок со всем контентом |
| return `<!-- wp:html -->\n${html}\n<!-- /wp:html -->`; |
| } |
|
|
| /** |
| * Разбить HTML на top-level элементы (теги + текстовые узлы). |
| */ |
| function splitTopLevelElements(html) { |
| const elements = []; |
| let i = 0; |
| const tagRe = /<(\w+)(\s[^>]*)?>([\s\S]*?)<\/\1>|<(\w+)(\s[^>]*)?\/?>/g; |
| let lastEnd = 0; |
| let m; |
|
|
| while ((m = tagRe.exec(html)) !== null) { |
| // текст перед тегом |
| if (m.index > lastEnd) { |
| const text = html.slice(lastEnd, m.index).trim(); |
| if (text && text !== " ") { |
| 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: "", bgBody: "", bgTop: "", bgInsertPage: "", bgFooter: "" }; |
|
|
| // найти master.css и reset.css в assetMap по паттернам имени |
| for (const [url, info] of assetMap) { |
| const lower = url.toLowerCase(); |
| if (/master\.css/i.test(lower) && !result.cssMaster) result.cssMaster = info.file; |
| if (/reset\.css/i.test(lower) && !result.cssReset) result.cssReset = info.file; |
| if (/logo/i.test(lower) && /\.(png|jpg|gif|svg)/i.test(info.file) && !result.logo) result.logo = info.file; |
| if (/bg_page|bg-body/i.test(lower) && !result.bgImage) result.bgImage = info.file; |
| if (/bg_header/i.test(lower) && !result.bgTop) result.bgTop = info.file; |
| if (/bg_insert_page/i.test(lower) && !result.bgInsertPage) result.bgInsertPage = info.file; |
| if (/bg_paticka|bg_footer/i.test(lower) && !result.bgFooter) result.bgFooter = info.file; |
| } |
|
|
| // из CSS найти фоновую картинку body (url(...) в правиле body{...}) |
| const allCss = cssTexts.join("\n"); |
| const bodyMatch = allCss.match(/body\s*\{[^}]*background\s*:\s*url\(([^)]+)\)/i); |
| if (bodyMatch && bodyMatch[1]) { |
| // найти этот файл в assetMap |
| const bgOrig = bodyMatch[1].replace(/^["']|["']$/g, ""); |
| const bgBase = bgOrig.split("/").pop(); |
| for (const [url, info] of assetMap) { |
| if (url.endsWith(bgBase) || url.includes(bgBase)) { result.bgImage = info.file; break; } |
| } |
| } |
|
|
| return result; |
| } |
|
|
| function shouldDownloadAsset(raw, attr) { |
| if (!raw || raw.startsWith("#") || /^(data:|mailto:|tel:|javascript:)/i.test(raw)) return false; |
| if (attr === "href" && !/\.(css|ico|png|jpe?g|gif|webp|svg|woff2?|ttf|otf)(\?|#|$)/i.test(raw)) return false; |
| return true; |
| } |
|
|
| function toAbsoluteAssetUrl(raw, base) { |
| try { |
| if (/^\/web\/\d+/i.test(raw)) { |
| const parsed = parseWaybackUrl(`https://web.archive.org${raw}`); |
| if (parsed) return parsed.original; |
| } |
| if (/web\.archive\.org\/web\/\d+/i.test(raw)) { |
| const parsed = parseWaybackUrl(raw); |
| if (parsed) return parsed.original; |
| } |
| return new URL(raw, base).href; |
| } catch { |
| return null; |
| } |
| } |
|
|
| async function fetchText(url) { |
| const response = await fetchWithTimeout(url, HTML_TIMEOUT_MS); |
| if (!response.ok) throw new Error(`Fetch failed ${response.status}: ${url}`); |
| return response.text(); |
| } |
|
|
| async function fetchJson(url) { |
| const response = await fetchWithTimeout(url, HTML_TIMEOUT_MS); |
| if (!response.ok) throw new Error(`Fetch failed ${response.status}: ${url}`); |
| return response.json(); |
| } |
|
|
| async function fetchWithTimeout(url, timeoutMs) { |
| const controller = new AbortController(); |
| const timer = setTimeout(() => controller.abort(), timeoutMs); |
| try { |
| return await fetch(url, { redirect: "follow", signal: controller.signal }); |
| } finally { |
| clearTimeout(timer); |
| } |
| } |
|
|
| async function zipDirectory(sourceDir, destination, options = {}) { |
| await fs.rm(destination, { force: true }); |
| const archivePath = options.includeRoot ? sourceDir : path.join(sourceDir, "*"); |
| await new Promise((resolve, reject) => { |
| const child = spawn("powershell", [ |
| "-NoProfile", |
| "-Command", |
| "Compress-Archive", |
| "-Path", |
| archivePath, |
| "-DestinationPath", |
| destination, |
| "-Force" |
| ], { stdio: "pipe" }); |
| let stderr = ""; |
| child.stderr.on("data", (data) => { stderr += data.toString(); }); |
| child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(stderr || `Compress-Archive exited ${code}`))); |
| }); |
| } |
|
|
| async function sendFile(res, file, attachment = false) { |
| try { |
| const data = await fs.readFile(file); |
| const mime = mimeForAssetFile(file, data); |
| res.writeHead(200, { |
| "Content-Type": mime, |
| "Content-Disposition": attachment ? `attachment; filename="${path.basename(file)}"` : `inline; filename="${path.basename(file)}"` |
| }); |
| res.end(data); |
| } catch { |
| notFound(res); |
| } |
| } |
|
|
| function readJson(req) { |
| return new Promise((resolve, reject) => { |
| let data = ""; |
| req.on("data", (chunk) => { data += chunk; }); |
| req.on("end", () => { |
| try { resolve(JSON.parse(data || "{}")); } catch (error) { reject(error); } |
| }); |
| req.on("error", reject); |
| }); |
| } |
|
|
| function sendJson(res, data, status = 200) { |
| res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); |
| res.end(JSON.stringify(data, null, 2)); |
| } |
|
|
| function notFound(res) { |
| res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); |
| res.end("Not found"); |
| } |
|
|
| function normalizeOriginalUrl(value) { |
| const url = String(value).trim(); |
| if (!/^https?:\/\//i.test(url)) return `https://${url}`; |
| return url; |
| } |
|
|
| function cleanDomain(value) { |
| return String(value || "").replace(/^https?:\/\//, "").replace(/\/.*$/, "").trim(); |
| } |
|
|
| function normalizeRequestPath(pathname) { |
| const trimmed = "/" + String(pathname || "/").replace(/^\/+|\/+$/g, ""); |
| return trimmed === "/" ? "/" : `${trimmed}/`; |
| } |
|
|
| function pathNameToFileStem(requestPath) { |
| return requestPath === "/" ? "home" : slugify(requestPath.replace(/^\/|\/$/g, "").replaceAll("/", "-")); |
| } |
|
|
| function titleFromPath(requestPath) { |
| if (requestPath === "/") return "Home"; |
| return requestPath.replace(/^\/|\/$/g, "").split("/").pop().replaceAll("-", " ").replace(/\b\w/g, (c) => c.toUpperCase()); |
| } |
|
|
| function extractTitle(html) { |
| const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); |
| return match ? decodeEntities(match[1].replace(/\s+/g, " ").trim()) : ""; |
| } |
|
|
| function decodeEntities(value) { |
| // Базовые сущности |
| value = value.replace(/&/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, "\\$&"); |
| } |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|