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"; // Liquid Glass: инлайн SVG displacement-фильтр, инъецируется во все страницы. // PNG-карта — мелкий равномерный шум (см. статью Хабра), даёт «живое» стекло в Chromium. // В Safari/Firefox url()-фильтр в backdrop-filter не рендерится — там остаётся чистый blur. const LIQUID_GLASS_FILTER = ``; // --- SEO --- const SITE_ORIGIN = "https://mamadaevv.site"; const OG_IMAGE_DEFAULT = `${SITE_ORIGIN}/assets/og/og-default.png`; const AUTHOR_NAME_RU = "Николай Мамадаев"; const AUTHOR_NAME_EN = "Nikolai Mamadaev"; const SITE_NAME_RU = "Николай Мамадаев — продуктовый дизайнер"; const SITE_NAME_EN = "Nikolai Mamadaev — Product Designer"; // Служебные страницы-каталоги: закрыты от индексации. const NOINDEX_PAGES = new Set([ "cases/design-system", "cases/template-elements-demo", "cases/mini-projects-showcase", ]); const titleRe = /([\s\S]*?)<\/title>/i; const descRe = /<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i; function decodeEntities(str) { return String(str) .replace(/ /g, " ") .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">"); } function extractMeta(html) { const t = html.match(titleRe); const d = html.match(descRe); return { title: t ? decodeEntities(t[1]).trim() : "", description: d ? decodeEntities(d[1]).trim() : "", }; } /** Путь страницы на сайте (без ведущего слэша, с закрывающим). */ function sitePath(relNorm, loc) { const base = relNorm .replace(/\.html$/, "") .replace(/(^|\/)index$/, "/"); const norm = relNorm.startsWith("cases/") ? `cases/${path.basename(relNorm, ".html")}/` : base === "resume" ? "resume/" : base === "cookie-policy" ? "cookie-policy/" : base === "" || base === "/" ? "" : `${base.replace(/\/$/, "")}/`; return loc === DEFAULT_LOCALE ? norm : `${loc}/${norm}`; } /** Канонический URL страницы. */ function canonicalFor(relNorm, loc) { const p = sitePath(relNorm, loc); return `${SITE_ORIGIN}/${p}`; } /** Дата изменения страницы (mtime исходника) в формате YYYY-MM-DD. */ function lastModFor(relNorm) { const src = path.join(srcPagesDir, relNorm); const enSrc = src.replace(/\.html$/, ".en.html"); let mtime = 0; if (fs.existsSync(src)) mtime = fs.statSync(src).mtimeMs; if (fs.existsSync(enSrc)) mtime = Math.max(mtime, fs.statSync(enSrc).mtimeMs); return new Date(mtime || Date.now()).toISOString().slice(0, 10); } /** JSON-LD для страницы: Person+WebSite на главной, CreativeWork на кейсах, BreadcrumbList везде. */ function buildJsonLd(relNorm, loc, meta, canonical) { const isRu = loc === DEFAULT_LOCALE; const authorName = isRu ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; const siteName = isRu ? SITE_NAME_RU : SITE_NAME_EN; const graph = []; const personId = `${SITE_ORIGIN}/#person`; const websiteId = `${SITE_ORIGIN}/#website`; const person = { "@type": "Person", "@id": personId, name: authorName, url: SITE_ORIGIN, jobTitle: isRu ? "Продуктовый дизайнер" : "Product Designer", description: isRu ? "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы." : "Product designer with 10+ years of experience. Designing digital products, interfaces and design systems.", knowsAbout: isRu ? ["UX/UI-дизайн", "продуктовый дизайн", "дизайн-системы", "прототипирование", "Figma", "no-code"] : ["UX/UI design", "product design", "design systems", "prototyping", "Figma", "no-code"], sameAs: ["https://t.me/mamadaevv", "https://vk.com/mamadaevv", "https://github.com/mamadaevv"], }; graph.push(person); graph.push({ "@type": "WebSite", "@id": websiteId, url: `${SITE_ORIGIN}/`, name: siteName, inLanguage: loc, author: { "@id": personId }, publisher: { "@id": personId }, }); const isCase = relNorm.startsWith("cases/"); const pageNode = { "@type": isCase ? "CreativeWork" : "WebPage", "@id": `${canonical}#page`, url: canonical, name: meta.title, description: meta.description, isPartOf: { "@id": websiteId }, author: { "@id": personId }, inLanguage: loc, }; if (isCase && !NOINDEX_PAGES.has(relNorm.replace(/\.html$/, ""))) { pageNode.creator = { "@id": personId }; pageNode.datePublished = lastModFor(relNorm); pageNode.about = isRu ? "UX/UI-дизайн и проектирование интерфейсов" : "UX/UI design and interface design"; } graph.push(pageNode); // Хлебные крошки для вложенных страниц. const parts = sitePath(relNorm, loc).split("/").filter(Boolean); if (parts.length > 0) { const crumbs = [{ "@type": "ListItem", position: 1, name: siteName, item: `${SITE_ORIGIN}/` }]; let acc = SITE_ORIGIN; let pos = 2; for (const part of parts) { if (part === loc) continue; acc += `/${part}`; crumbs.push({ "@type": "ListItem", position: pos++, name: part === "cases" ? (isRu ? "Кейсы" : "Cases") : meta.title || part, item: `${acc}/`, }); } if (crumbs.length > 1) { graph.push({ "@type": "BreadcrumbList", "@id": `${canonical}#breadcrumbs`, itemListElement: crumbs, }); } } return { "@context": "https://schema.org", "@graph": graph, }; } /** Генерация sitemap.xml с hreflang-альтернатами. */ function buildSitemap(pageFiles) { const urls = []; const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); for (const pagePath of pageFiles) { const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/"); const noSlug = relNorm.replace(/\.html$/, ""); if (NOINDEX_PAGES.has(noSlug)) continue; const lastmod = lastModFor(relNorm); const locUrl = canonicalFor(relNorm, DEFAULT_LOCALE); const alternates = LOCALES.map( (loc) => ` <xhtml:link rel="alternate" hreflang="${loc}" href="${canonicalFor(relNorm, loc)}" />` ); urls.push( ` <url>\n <loc>${locUrl}</loc>\n <lastmod>${lastmod}</lastmod>\n` + ` <xhtml:link rel="alternate" hreflang="x-default" href="${locUrl}" />\n` + alternates.join("\n") + `\n </url>` ); } return ( `<?xml version="1.0" encoding="UTF-8"?>\n` + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` + ` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + urls.join("\n") + `\n</urlset>\n` ); } 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("<article", search); if (i === -1) return html; const tagEnd = 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("</article>", contentStart); if (closeIdx === -1) return html; const afterArticle = closeIdx + "</article>".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); // --- SEO-переменные для head-seo / json-ld --- const noSlug = relNorm.replace(/\.html$/, ""); const isNoindex = NOINDEX_PAGES.has(noSlug); const canonicalUrl = canonicalFor(relNorm, loc); const metaSource = extractMeta(source); const pageTitle = metaSource.title || (isDefault ? SITE_NAME_RU : SITE_NAME_EN); const pageDesc = metaSource.description || pageTitle; const isCasePage = relNorm.startsWith("cases/"); const vars = baseVars(relNorm, depth); vars.canonicalUrl = canonicalUrl; vars.canonicalRu = canonicalFor(relNorm, "ru"); vars.canonicalEn = canonicalFor(relNorm, "en"); vars.authorName = isDefault ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; vars.siteName = isDefault ? SITE_NAME_RU : SITE_NAME_EN; vars.metaRobots = isNoindex ? "noindex, nofollow" : "index, follow"; vars.themeColor = "#0f1115"; vars.ogType = isCasePage ? "article" : "website"; vars.ogLocale = isDefault ? "ru_RU" : "en_US"; vars.ogLocaleAlt = isDefault ? "en_US" : "ru_RU"; vars.ogTitle = pageTitle; vars.ogDescription = pageDesc; vars.ogImage = OG_IMAGE_DEFAULT; vars.ogImageAlt = pageTitle; vars.jsonLd = JSON.stringify(buildJsonLd(relNorm, loc, { title: pageTitle, description: pageDesc }, canonicalUrl), null, 2); // Префикс до КОРНЯ ЛОКАЛИ (без en-сдвига) — для навигационных ссылок, // чтобы они вели в ту же локаль (/en/), а не в корень сайта (/). vars.pagePrefix = vars.assetPrefix; // Для не-дефолтной локали всё содержимое уходит на уровень глубже (public/<lang>/), // поэтому к 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); // Подменяем язык в <html lang="..."> под текущую локаль. rendered = rendered.replace(/<html\s+lang="ru"/, `<html lang="${loc}"`); if (isCase) { rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile); } // Liquid Glass: инъецируем инлайн SVG displacement-фильтр сразу после <body>. const lgSvg = LIQUID_GLASS_FILTER.replace(/{{LG_ASSET_PREFIX}}/g, vars.assetPrefix); rendered = rendered.replace(/(<body[^>]*>)/, `$1\n ${lgSvg}\n`); ensureDir(path.dirname(outPath)); fs.writeFileSync(outPath, rendered, "utf8"); } } const sitemapXml = buildSitemap(pageFiles); fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml, "utf8"); console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`); console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`); } build();