import fs from "node:fs"; import path from "node:path"; const rootDir = process.cwd(); const srcPagesDir = path.join(rootDir, "src", "pages"); const srcPartialsDir = path.join(rootDir, "src", "partials"); const srcI18nDir = path.join(rootDir, "src", "i18n"); const outDir = path.join(rootDir, "public"); // Локали: дефолтная (ru) собирается в корень public/, остальные — в public//. const LOCALES = ["ru", "en"]; const DEFAULT_LOCALE = "ru"; const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi; const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g; const i18nRe = /\{\{\s*i18n:([\w.-]+)\s*\}\}/g; function readUtf8(filePath) { return fs.readFileSync(filePath, "utf8"); } function walkHtmlFiles(dir, acc = []) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const abs = path.join(dir, entry.name); if (entry.isDirectory()) { walkHtmlFiles(abs, acc); continue; } if (entry.isFile() && entry.name.endsWith(".html")) { acc.push(abs); } } return acc; } function loadPartials() { const partialMap = new Map(); for (const filePath of walkHtmlFiles(srcPartialsDir)) { const name = path.basename(filePath, ".html"); partialMap.set(name, readUtf8(filePath)); } return partialMap; } function loadI18n() { const map = {}; for (const loc of LOCALES) { const file = path.join(srcI18nDir, `${loc}.json`); map[loc] = fs.existsSync(file) ? JSON.parse(readUtf8(file)) : {}; } return map; } function resolveI18n(key, dict, fallback) { if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return String(dict[key]); if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return String(fallback[key]); return `{{i18n:${key}}}`; } function renderTemplate(input, partials, vars, dict, fallback, loc, stack = []) { const withIncludes = input.replace(includeRe, (_, name) => { const key = String(name).trim(); const partialKey = loc && loc !== DEFAULT_LOCALE && partials.has(`${key}.${loc}`) ? `${key}.${loc}` : key; if (!partials.has(partialKey)) { throw new Error(`Partial "${key}" not found`); } if (stack.includes(partialKey)) { throw new Error(`Circular partial include: ${[...stack, partialKey].join(" -> ")}`); } const partialContent = partials.get(partialKey); return renderTemplate(partialContent, partials, vars, dict, fallback, loc, [...stack, partialKey]); }); const withI18n = withIncludes.replace(i18nRe, (_, key) => resolveI18n(String(key).trim(), dict, fallback)); return withI18n.replace(varRe, (_, name) => { const key = String(name).trim(); if (Object.prototype.hasOwnProperty.call(vars, key)) { return String(vars[key]); } return `{{${key}}}`; }); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } /** На странице кейса убираем из HTML карусели карточку со ссылкой на текущий файл (href уже с подставленным casesPrefix). */ function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) { if (!omitHrefBasename) return html; const slug = omitHrefBasename.replace(/\.html$/, ""); const hrefDouble = `href="${slug}/"`; const hrefSingle = `href='${slug}/'`; const itemClass = "projects-carousel__item"; let search = 0; while (true) { const i = html.indexOf("", i); if (tagEnd === -1) return html; const openTag = html.slice(i, tagEnd + 1); if (!openTag.includes(itemClass)) { search = i + 9; continue; } const contentStart = tagEnd + 1; const closeIdx = html.indexOf("", contentStart); if (closeIdx === -1) return html; const afterArticle = closeIdx + "".length; const block = html.slice(i, afterArticle); if (block.includes(hrefDouble) || block.includes(hrefSingle)) { return html.slice(0, i) + html.slice(afterArticle); } search = contentStart; } } /** Базовые переменны для страницы в дефолтной локали (как было до i18n). */ function baseVars(relNorm, depth) { if (relNorm.startsWith("cases/")) { return { assetPrefix: "../../", casesPrefix: "../" }; } if (relNorm === "resume.html") { return { assetPrefix: "../", casesPrefix: "" }; } if (relNorm === "cookie-policy.html") { return { assetPrefix: "../", casesPrefix: "" }; } return { assetPrefix: depth === 0 ? "" : "../".repeat(depth), casesPrefix: depth === 0 ? "cases/" : "", }; } function build() { const partials = loadPartials(); const i18n = loadI18n(); const defaultDict = i18n[DEFAULT_LOCALE] || {}; const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); const pageFiles = walkHtmlFiles(srcPagesDir).filter((p) => !localeSuffixRe.test(p)); for (const pagePath of pageFiles) { const rel = path.relative(srcPagesDir, pagePath); const relNorm = rel.split(path.sep).join("/"); const pageDirRel = path.dirname(rel); const depth = pageDirRel === "." ? 0 : pageDirRel.split(path.sep).length; const isCase = relNorm.startsWith("cases/"); const isResume = relNorm === "resume.html"; const isCookiePolicy = relNorm === "cookie-policy.html"; const omitFile = isCase ? path.basename(pagePath) : null; for (const loc of LOCALES) { const dict = i18n[loc] || {}; const isDefault = loc === DEFAULT_LOCALE; const langPrefix = isDefault ? "" : `${loc}/`; // Переведённый исходник страницы для локали, если есть. let sourcePath = pagePath; if (!isDefault) { const override = pagePath.replace(/\.html$/, `.${loc}.html`); if (fs.existsSync(override)) sourcePath = override; } const source = readUtf8(sourcePath); const vars = baseVars(relNorm, depth); // Для не-дефолтной локали всё содержимое уходит на уровень глубже (public//), // поэтому к assetPrefix добавляем один ../, а пустой casesPrefix — в ../cases/. if (!isDefault) { vars.assetPrefix = vars.assetPrefix === "" ? "../" : vars.assetPrefix + "../"; if (vars.casesPrefix === "") vars.casesPrefix = "../cases/"; } vars.lang = loc; let outPath; if (isCase) { const slug = path.basename(pagePath, ".html"); outPath = path.join(outDir, langPrefix, "cases", slug, "index.html"); } else if (isResume) { outPath = path.join(outDir, langPrefix, "resume", "index.html"); } else if (isCookiePolicy) { outPath = path.join(outDir, langPrefix, "cookie-policy", "index.html"); } else { outPath = path.join(outDir, langPrefix, rel); } let rendered = renderTemplate(source, partials, vars, dict, defaultDict, loc); // Подменяем язык в под текущую локаль. rendered = rendered.replace(/