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"; // SVG-displacement больше не используется: всё стекло — через CSS blur. // --- 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", ]); // Страницы, у которых пока нет перевода: собираются только в дефолтную локаль, // чтобы /en/ не отдавал русскоязычный дубль и не попадал в sitemap. const DEFAULT_LOCALE_ONLY_PAGES = new Set(["ai-dev"]); // Своя og-картинка для страницы основного AI Server-кейса. const PER_PAGE_OG_IMAGE = { "cases/ai-platform-primo-rpa": `${SITE_ORIGIN}/assets/og/og-primo-star.png`, }; // По одной строке на страницу — попадает в llms.txt. // Пишется от руки: description из у кейсов начинается со слов «Кейс:» // и сам по себе ни о чём не говорит агенту. Правило: не повторять имя ссылки // (оно уже стоит слева от двоеточия) и не писать «Кейс: …». const LLMS_PAGE_NOTES = { "": "Короткое описание, портфолио из кейсов и лента активности.", resume: "Опыт работы, стек, проекты, навыки и контакты.", "ai-dev": "Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты.", "cookie-policy": "Какие cookie и localStorage использует сайт.", "cases/ai-assistant-figma": "Получает скрипты из Cursor и выполняет их в открытом файле.", "cases/ai-platform-primo-rpa": "Фреймворк STAR для настройки проектов в AI Server.", "cases/ai-platform-primo-rpa-v2": "Редизайн интерфейса AI Server.", "cases/ai-platform-primo-rpa-2": "Черновик 2: OCR на документах, дообучение моделей, skills.", "cases/card-columns-obsidian": "Канбан-доски по свойствам заметок, фильтры AND/OR, перенос карточек.", "cases/lunacy": "Icons8: пользовательские сценарии, итерации интерфейса и скриншоты процесса.", "cases/manu-game-maker": "Визуальная система и сложные пользовательские сценарии.", "cases/mobile-clinic-app": "Запись к врачу, услуги, навигация, быстрые действия.", "cases/obsidian-plugin-columns-view": "Дизайн и UX плагина для заметок.", "cases/orc-automation-panel": "Primo RPA: кураторство дизайна, переход на Ant Design и Storybook, токены в Figma.", "cases/site-portfolio": "От no-code конструкторов до разработки в Cursor и Hermes Agent.", "cases/task-distribution-concept": "Прозрачность процессов между командами.", "cases/task-tracker": "Вкладки, фильтры, виды отображения, прототипы и пользовательские исследования.", }; // Имя ссылки, когда заголовок страницы не отвечает на вопрос «что это». // Заодно снимаем хвост «— кейс» / «— Case Study»: в секции «Кейсы» он лишний. const LLMS_PAGE_NAMES = { "cases/ai-platform-primo-rpa-v2": "AI Server в Primo RPA: редизайн", "cases/ai-platform-primo-rpa-2": "AI Server в Primo RPA: черновик 2", "cases/card-columns-obsidian": "Card Columns для Obsidian Bases", }; 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 === "ai-dev" ? "ai-dev/" : 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 buildLocales = DEFAULT_LOCALE_ONLY_PAGES.has(noSlug) ? [DEFAULT_LOCALE] : LOCALES; const alternates = buildLocales.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` ); } /** Генерация llms.txt: короткая карта сайта для LLM-агентов (спецификация llmstxt.org). */ function buildLlmsTxt(pageFiles) { const entries = []; for (const pagePath of pageFiles) { const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/"); // Тот же slug, что и в sitePath(): index.html → "" (главная). const slug = relNorm .replace(/\.html$/, "") .replace(/(^|\/)index$/, "") .replace(/\/$/, ""); if (NOINDEX_PAGES.has(slug)) continue; const { title } = extractMeta(readUtf8(pagePath)); // Имя ссылки: из <title> без хвоста с фамилией автора и без «— кейс», // но с явным переопределением там, где заголовок говорит только «AI Server». const strippedTitle = title ? title .replace(/\s*[·|—-]\s*Никол(ая|ай) Мамадаев\s*$/i, "") .replace(/\s*[—–-]\s*(кейс|case study)\s*$/i, "") .trim() : ""; const name = LLMS_PAGE_NAMES[slug] || (slug === "" ? "Главная" : strippedTitle || slug); entries.push({ slug, name, url: canonicalFor(relNorm, DEFAULT_LOCALE) }); } const cases = entries.filter((e) => e.slug.startsWith("cases/")); const rest = entries.filter((e) => !e.slug.startsWith("cases/")); /** Строка списка: описание дописывается, только если не повторяет имя ссылки. */ const line = (e) => { const note = LLMS_PAGE_NOTES[e.slug] || ""; if (!note) return `- [${e.name}](${e.url})`; // Описание, начинающееся именем ссылки, обрезаем: «Имя: Имя: …» агентам не помогает. const prefix = `${e.name}: `; const trimmed = note.startsWith(prefix) ? note.slice(prefix.length) : note; return `- [${e.name}](${e.url}): ${trimmed}`; }; const section = (heading, list) => `## ${heading}\n\n${list.map(line).join("\n")}\n`; return ( `# ${SITE_NAME_RU}\n\n` + `> Сайт-портфолио продуктового дизайнера Николая Мамадаева: кейсы UX/UI-проектов, резюме, ` + `описание AI-разработки и контакты. Русский и английский, английские страницы лежат в /en/.\n\n` + `Формат: [llms.txt](https://llmstxt.org/). Карта для поисковиков: ` + `https://mamadaevv.site/sitemap.xml, правила для краулеров: https://mamadaevv.site/robots.txt.\n\n` + `Ссылки ведут на HTML-страницы, отдельной markdown-версии у сайта нет. ` + `Страницы-каталоги дизайн-системы (/cases/design-system/, /cases/template-elements-demo/, ` + `/cases/mini-projects-showcase/) закрыты от индексации и сюда не попали.\n\n` + section("Основные страницы", rest) + "\n" + section("Кейсы", cases) + "\n" + `## Контакты\n\n` + `- [Telegram](https://t.me/mamadaevv): @mamadaevv\n` + `- [Почта](mailto:nik@mamadaevv.site): nik@mamadaevv.site\n\n` + `## Использование\n\n` + `Цитировать кейсы и текст можно со ссылкой на страницу. Ссылка на сайт обязательна.\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: "" }; } if (relNorm === "ai-dev.html") { return { assetPrefix: "../", casesPrefix: "../cases/" }; } 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 isAiDev = relNorm === "ai-dev.html"; const omitFile = isCase ? path.basename(pagePath) : null; const noSlug = relNorm.replace(/\.html$/, ""); for (const loc of LOCALES) { if (loc !== DEFAULT_LOCALE && DEFAULT_LOCALE_ONLY_PAGES.has(noSlug)) continue; 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 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"); // Для ru-only страниц en-альтернаты не существует — отдаём ru, // иначе hreflang указывает на несуществующий /en/... URL. vars.canonicalEn = DEFAULT_LOCALE_ONLY_PAGES.has(noSlug) ? vars.canonicalRu : 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 = PER_PAGE_OG_IMAGE[noSlug] || OG_IMAGE_DEFAULT; vars.ogImageAlt = pageTitle; // Link-relation v2 спецификации llms.txt: файл, покрывающий страницу. vars.llmsUrl = `${SITE_ORIGIN}/llms.txt`; 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 if (isAiDev) { outPath = path.join(outDir, langPrefix, "ai-dev", "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); } ensureDir(path.dirname(outPath)); fs.writeFileSync(outPath, rendered, "utf8"); } } const sitemapXml = buildSitemap(pageFiles); fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml, "utf8"); const llmsTxt = buildLlmsTxt(pageFiles); fs.writeFileSync(path.join(outDir, "llms.txt"), llmsTxt, "utf8"); console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`); console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`); console.log(`Generated llms.txt (${llmsTxt.split("\n").filter((l) => l.startsWith("- [")).length} links)`); } build();