feat: страница /ai-dev/ — AI-разработка и проекты, карточка в Активности

This commit is contained in:
Nikolay Mamadaev committed 2026-10-07 12:52:10 +02:00
1 parent 39dad71bd1
commit 52485a17c4
10 files changed
+1895 -810

No files matched your search

+653
View File
@@ -0,0 +1,653 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Николай Мамадаев, AI-разработчик. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты."
/>
<title>AI-разработка · Николай Мамадаев</title>
<script>
(function () {
var FONT_CSS = {
merriweather: "/assets/fonts/merriweather.css",
geologica: "/assets/fonts/geologica.css",
mono: "/assets/fonts/mono.css",
};
try {
var t = JSON.parse(localStorage.getItem("siteThemeV1") || "{}");
var d = document.documentElement;
var m = t.mode;
if (m !== "light" && m !== "dark" && m !== "system") m = "system";
var eff =
m === "system"
? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: m;
d.dataset.theme = eff;
var DEF = { h: 210, s: 90, l: 40 };
function pickAccent(o) {
if (!o) return null;
var h = typeof o.h === "number" ? o.h : typeof o.hue === "number" ? o.hue : null;
var s = typeof o.s === "number" ? o.s : typeof o.sat === "number" ? o.sat : null;
var l = typeof o.l === "number" ? o.l : typeof o.light === "number" ? o.light : null;
if (h === null && s === null && l === null) return null;
return {
h: h === null ? DEF.h : h,
s: s === null ? DEF.s : Math.min(100, Math.max(30, s)),
l: l === null ? DEF.l : Math.min(70, Math.max(20, l)),
};
}
// Текущий формат: отдельный акцент для светлой и тёмной темы ({light, dark}).
// Старый плоский формат ({h, s, l}) остаётся совместимым.
var acc =
pickAccent(eff === "dark" ? t.dark : t.light) ||
pickAccent(eff === "dark" ? t.light : t.dark) ||
pickAccent(t) ||
DEF;
d.style.setProperty("--theme-h", String(acc.h));
d.style.setProperty("--theme-s", String(acc.s));
d.style.setProperty("--theme-l", String(acc.l));
var H = (((acc.h % 360) + 360) % 360) / 360;
var S = Math.min(100, Math.max(0, acc.s)) / 100;
var Lv = Math.min(100, Math.max(0, acc.l)) / 100;
var r, g, b;
if (S === 0) { r = g = b = Lv; }
else {
var q = Lv < 0.5 ? Lv * (1 + S) : Lv + S - Lv * S;
var p = 2 * Lv - q;
var hue2rgb = function(p, q, t) {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
r = hue2rgb(p, q, H + 1/3);
g = hue2rgb(p, q, H);
b = hue2rgb(p, q, H - 1/3);
}
var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
d.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)");
var rad = t.radius;
if (rad === "soft" || rad === "medium" || rad === "strong") {
d.dataset.radius = rad;
} else {
d.dataset.radius = "medium";
}
var f = t.font;
if (f === "onest") f = "geologica";
if (f === "anonymous") f = "mono";
if (f !== "merriweather" && f !== "mono" && f !== "geologica") f = "geologica";
d.dataset.font = f;
var mb = t.meshBg;
if (mb === "off") {
d.dataset.meshBg = "off";
} else {
d.dataset.meshBg = "on";
}
var href = FONT_CSS[f];
if (href && !document.querySelector('link[data-theme-font="' + f + '"]')) {
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-theme-font", f);
document.head.appendChild(link);
}
} catch (e) {}
})();
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
<link rel="preload" href="/assets/fonts/merriweather.css" as="style" />
<link rel="preload" href="/css/main.css" as="style" />
<link rel="preload" href="/js/skill-connectors.js" as="script" />
<link rel="modulepreload" href="/js/main.js" />
<link rel="modulepreload" href="/js/modules/ui/page-mesh-shader.js" />
<link rel="stylesheet" href="/css/main.css" />
<link rel="canonical" href="https://mamadaevv.site/ai-dev/" />
<link rel="alternate" hreflang="ru" href="https://mamadaevv.site/ai-dev/" />
<link rel="alternate" hreflang="en" href="https://mamadaevv.site/ai-dev/" />
<link rel="alternate" hreflang="x-default" href="https://mamadaevv.site/ai-dev/" />
<meta name="author" content="Николай Мамадаев" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#0f1115" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Николай Мамадаев — продуктовый дизайнер" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:locale:alternate" content="en_US" />
<meta property="og:title" content="AI-разработка · Николай Мамадаев" />
<meta property="og:description" content="Николай Мамадаев, AI-разработчик. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты." />
<meta property="og:url" content="https://mamadaevv.site/ai-dev/" />
<meta property="og:image" content="https://mamadaevv.site/assets/og/og-default.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="AI-разработка · Николай Мамадаев" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="AI-разработка · Николай Мамадаев" />
<meta name="twitter:description" content="Николай Мамадаев, AI-разработчик. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты." />
<meta name="twitter:image" content="https://mamadaevv.site/assets/og/og-default.png" />
<meta name="twitter:image:alt" content="AI-разработка · Николай Мамадаев" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://mamadaevv.site/#person",
"name": "Николай Мамадаев",
"url": "https://mamadaevv.site",
"jobTitle": "Продуктовый дизайнер",
"description": "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы.",
"knowsAbout": [
"UX/UI-дизайн",
"продуктовый дизайн",
"дизайн-системы",
"прототипирование",
"Figma",
"no-code"
],
"sameAs": [
"https://t.me/mamadaevv",
"https://vk.com/mamadaevv",
"https://github.com/mamadaevv"
]
},
{
"@type": "WebSite",
"@id": "https://mamadaevv.site/#website",
"url": "https://mamadaevv.site/",
"name": "Николай Мамадаев — продуктовый дизайнер",
"inLanguage": "ru",
"author": {
"@id": "https://mamadaevv.site/#person"
},
"publisher": {
"@id": "https://mamadaevv.site/#person"
}
},
{
"@type": "WebPage",
"@id": "https://mamadaevv.site/ai-dev/#page",
"url": "https://mamadaevv.site/ai-dev/",
"name": "AI-разработка · Николай Мамадаев",
"description": "Николай Мамадаев, AI-разработчик. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты.",
"isPartOf": {
"@id": "https://mamadaevv.site/#website"
},
"author": {
"@id": "https://mamadaevv.site/#person"
},
"inLanguage": "ru"
},
{
"@type": "BreadcrumbList",
"@id": "https://mamadaevv.site/ai-dev/#breadcrumbs",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Николай Мамадаев — продуктовый дизайнер",
"item": "https://mamadaevv.site/"
},
{
"@type": "ListItem",
"position": 2,
"name": "AI-разработка · Николай Мамадаев",
"item": "https://mamadaevv.site/ai-dev/"
}
]
}
]
}
</script>
<script src="/js/skill-connectors.js" defer></script>
<script type="module" src="/js/main.js"></script>
<link rel="stylesheet" href="/vendor/plyr/plyr.css" />
<style>
.ai-page {
max-width: 52rem;
margin: 0 auto;
}
.ai-page h2 {
margin: 2.25rem 0 0.85rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.ai-page .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.ai-page a:not(.btn) {
color: var(--accent);
}
/* Сетка карточек проектов: 2 колонки, схлопывается в одну на мобильном */
.ai-projects {
list-style: none;
margin: 1.25rem 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
}
.ai-projects > li {
margin: 0;
}
.ai-projects__media {
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--line);
margin-bottom: 0.7rem;
background: var(--surface);
}
.ai-projects__media img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.ai-projects__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.ai-projects__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.ai-projects__stack {
margin: 0.3rem 0 0;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.78rem;
color: var(--text-muted);
}
.ai-projects__desc {
margin: 0.45rem 0 0;
font-size: 0.95rem;
color: var(--text);
}
.ai-projects__links {
list-style: none;
margin: 0.6rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
font-size: 0.85rem;
}
/* Навыки — компактные чипы переиспользуют стили тегов карточек активности */
.ai-stack-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.ai-stack-list li {
margin: 0;
padding: 0.3rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
</head>
<body>
<a class="skip-link" href="#case-main">К содержимому кейса</a>
<header class="header" data-theme-picker>
<div class="header__inner">
<a class="logo" href="../" aria-label="На главную">НМ</a>
<div class="header__cluster">
<nav class="nav" aria-label="Разделы страницы">
<a href="#contact">Контакты</a>
</nav>
</div>
</div>
</header>
<div class="fab-stack" aria-label="Быстрые действия">
<div class="theme-panel-fab-wrap">
<button type="button" class="fab fab--secondary glass-neutral" data-theme-panel-btn aria-expanded="false" aria-controls="theme-panel" aria-label="Настройки стиля">
<span class="theme-panel-fab__icon theme-panel-fab__icon--open-hide" aria-hidden="true"></span>
<svg class="theme-panel-fab__icon theme-panel-fab__icon--close" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="theme-panel" class="theme-picker__panel glass-neutral" role="dialog" aria-label="Тема и оформление" hidden data-theme-panel>
<div class="theme-picker__stack">
<section class="theme-picker__section" aria-labelledby="theme-accent-title">
<h3 id="theme-accent-title" class="theme-picker__section-title">Акцентный цвет</h3>
<div class="colorpicker" data-colorpicker>
<div class="colorpicker__plane-box">
<canvas class="colorpicker__plane" width="320" height="180" data-sl-canvas aria-label="Насыщенность и светлость акцента"></canvas>
<div class="colorpicker__knob" data-sl-knob></div>
</div>
<div class="colorpicker__hue-wrap">
<label class="colorpicker__hue-sr" for="theme-hue-slider">Оттенок акцента</label>
<input id="theme-hue-slider" class="colorpicker__hue" type="range" min="0" max="360" value="210" data-hue-slider aria-valuemin="0" aria-valuemax="360" />
</div>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mode-title">
<h3 id="theme-mode-title" class="theme-picker__section-title">Тема</h3>
<div class="radius-seg theme-mode-seg" role="radiogroup" aria-labelledby="theme-mode-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="system" aria-label="Как в системе"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--system"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="light" aria-label="Светлая тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--light"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-theme-mode="dark" aria-label="Тёмная тема"><span class="radius-seg__viz" aria-hidden="true"><span class="theme-mode-seg__ico theme-mode-seg__ico--dark"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-radius-title">
<h3 id="theme-radius-title" class="theme-picker__section-title">Скругление углов</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-radius-title">
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="soft" aria-label="Слабое скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--soft"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="medium" aria-label="Среднее скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--medium"></span></span></button>
<button type="button" class="radius-seg__btn" role="radio" aria-checked="false" data-radius-option="strong" aria-label="Сильное скругление"><span class="radius-seg__viz" aria-hidden="true"><span class="radius-seg__arc radius-seg__arc--strong"></span></span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-typography-title">
<h3 id="theme-typography-title" class="theme-picker__section-title">Типографика</h3>
<div class="radius-seg" role="radiogroup" aria-labelledby="theme-typography-title">
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="geologica" aria-label="Sans"><span class="theme-font-seg__label">Sans</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="merriweather" aria-label="Sans Serif"><span class="theme-font-seg__label">Sans Serif</span></button>
<button type="button" class="radius-seg__btn theme-font-seg__btn" role="radio" aria-checked="false" data-font-option="mono" aria-label="Mono"><span class="theme-font-seg__label">Mono</span></button>
</div>
</section>
<section class="theme-picker__section" aria-labelledby="theme-mesh-bg-title">
<h3 id="theme-mesh-bg-title" class="theme-picker__section-title">Фон страницы</h3>
<div class="radius-seg radius-seg--two" role="radiogroup" aria-labelledby="theme-mesh-bg-title">
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="on" aria-label="Показать фоновый градиент"><span class="theme-mesh-bg-seg__label">Вкл</span></button>
<button type="button" class="radius-seg__btn theme-mesh-bg-seg__btn" role="radio" aria-checked="false" data-mesh-bg-option="off" aria-label="Скрыть фоновый градиент"><span class="theme-mesh-bg-seg__label">Выкл</span></button>
</div>
</section>
</div>
</div>
</div>
<a class="fab fab--primary" href="#contact" aria-label="Связаться">
<span class="fab-primary__ico" aria-hidden="true"></span>
</a>
</div>
<div class="content-edit-fab-stack" aria-label="Правки контента" data-content-edit-ui>
<button
type="button"
class="fab fab--secondary glass-neutral content-edit-fab"
data-content-edit-mode-toggle
hidden
aria-pressed="false"
aria-label="Режим правок контента"
title="Режим правок"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20h4l10.5-10.5a2.12 2.12 0 0 0 0-3L16.5 4.5a2.12 2.12 0 0 0-3 0L3 15v5z"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span class="content-edit-fab__badge" hidden aria-hidden="true">0</span>
</button>
</div>
<main id="case-main" class="section section--tight">
<div class="section__inner ai-page">
<p class="hero__eyebrow">AI-разработка · идея, код, деплой</p>
<h1 class="case-page__h1">Делаю целые продукты</h1>
<p class="case-page__sub">Telegram-боты, MCP-серверы, плагины и утилиты. Схема, код, деплой</p>
<p class="section__prose">
Больше десяти лет я проектировал интерфейсы. Теперь довожу идеи до работающего
кода: бэкенд, база данных, деплой. Инфраструктура тоже моя часть работы.
В большинстве проектов код пишет ИИ-агент. Я задаю архитектуру, проверяю
результат и отвечаю за то, что продукт вообще работает.
</p>
<h2 id="roles">Что я делаю</h2>
<ul class="section__prose ai-stack-list">
<li>Продуктовая логика и API</li>
<li>Telegram-боты на aiogram 3</li>
<li>MCP-серверы</li>
<li>Плагины Figma и Obsidian</li>
<li>Автоматизация и агентные пайплайны</li>
</ul>
<h2 id="projects">Проекты</h2>
<ol class="ai-projects">
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-01.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Personal Assistant</h3>
</div>
<p class="ai-projects__stack">Python · aiogram 3 · PostgreSQL</p>
<p class="ai-projects__desc">
Личный ассистент в Telegram: напоминания, планирование дел и работа с LLM
в одном диалоге.
</p>
<ul class="ai-projects__links">
<li><a href="../cases/ai-platform-primo-rpa/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-02.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Image Gen Bot</h3>
</div>
<p class="ai-projects__stack">Python · aiogram 3 · Redis · PostgreSQL</p>
<p class="ai-projects__desc">
Бот для генерации изображений: баланс, реферальная программа, админ-логи
в отдельные топики супергруппы.
</p>
<ul class="ai-projects__links">
<li><a href="../cases/ai-platform-primo-rpa/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-03.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">MCP Image Server</h3>
</div>
<p class="ai-projects__stack">JavaScript · MCP · OpenRouter</p>
<p class="ai-projects__desc">
Собственный MCP-сервер для генерации картинок. Подключается к агентам
как инструмент.
</p>
<ul class="ai-projects__links">
<li><a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/mcp-img-gen" target="_blank" rel="noopener noreferrer">Код</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-01.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">AI assistant</h3>
</div>
<p class="ai-projects__stack">TypeScript · Figma Plugin API</p>
<p class="ai-projects__desc">
Плагин для Figma, который исполняет скрипты прямо в редакторе.
ИИ-ассистент для любого тарифа.
</p>
<ul class="ai-projects__links">
<li><a href="../cases/ai-assistant-figma/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-02.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Card Columns</h3>
</div>
<p class="ai-projects__stack">TypeScript · Obsidian API</p>
<p class="ai-projects__desc">
Вью для заметок в стиле канбан-доски: группировка по колонкам с фильтрами
по тегам.
</p>
<ul class="ai-projects__links">
<li><a href="../cases/card-columns-obsidian/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-03.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Hermes Desktop Plugins</h3>
</div>
<p class="ai-projects__stack">JavaScript · TypeScript · Electron</p>
<p class="ai-projects__desc">
Плагины для рабочего стола Hermes: чип баланса API, звук завершения задачи
и контроль трат.
</p>
<ul class="ai-projects__links">
<li><a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/unorouter-balance" target="_blank" rel="noopener noreferrer">Код</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-01.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Social Post Writer</h3>
</div>
<p class="ai-projects__stack">Python · aiogram 3 · SQLite</p>
<p class="ai-projects__desc">
Бот, который превращает тезисы в готовый пост под нужную площадку и тон,
учитывая голос автора.
</p>
<ul class="ai-projects__links">
<li><a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/social-post-writer-bot" rel="noopener noreferrer">Код</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="../assets/ai-dev/placeholder-02.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">AI Design Assistant</h3>
</div>
<p class="ai-projects__stack">HTML · CSS · GLSL · WebGL</p>
<p class="ai-projects__desc">
Этот сайт целиком: структура, дизайн-система, шейдеры, CI/CD.
Весь цикл создания вместе с ИИ-агентом.
</p>
<ul class="ai-projects__links">
<li><a href="../cases/site-portfolio/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
</ol>
<h2 id="stack">Стек</h2>
<ul class="section__prose ai-stack-list">
<li>Python</li>
<li>TypeScript</li>
<li>JavaScript</li>
<li>Go</li>
<li>HTML/CSS</li>
<li>GLSL</li>
<li>aiogram 3</li>
<li>FastAPI</li>
<li>PostgreSQL</li>
<li>Redis</li>
<li>SQLite</li>
<li>Docker</li>
<li>Coolify</li>
<li>Gitea</li>
<li>MCP</li>
</ul>
<h2 id="links">Где я это выкладываю</h2>
<p class="section__prose">
Код открыт: <a href="https://github.com/mamadaevv" target="_blank" rel="noopener noreferrer">GitHub</a>
и <a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin" target="_blank" rel="noopener noreferrer">Gitea</a>.
Сервисы, которые работают прямо сейчас, живут на
<a href="https://secrets-4-me.dynv6.net" target="_blank" rel="noopener noreferrer">secrets-4-me</a>.
</p>
</div>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

+207 -191
View File
@@ -818,197 +818,213 @@
</div>
</div>
</section>
<section id="activity" class="section section--surface" aria-labelledby="mini-projects-title" data-reveal>
<div class="section__inner">
<h2 id="mini-projects-title" class="section__title">Активность</h2>
<div
class="skills-intro skills-intro--chat section__prose"
data-skills-intro
role="region"
aria-labelledby="mini-projects-title"
>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__author">Николай М:</p>
<p class="skills-msg__text">
Люблю делать разные мини-проекты или делиться чем-то полезным с миром.
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
</div>
<ol id="mini-projects-list" class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/ai-assistant-figma/"
data-glass="false"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">ИИ помощник для любого тарифа редактора</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/card-columns-obsidian/"
data-glass="false"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Отображение карточек с заметками в стиле канбан доски</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/site-portfolio/"
data-glass="false"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Весь цикл создания сайта при помощи ИИ</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://www.figma.com/@mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Figma Community — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/figma_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Figma Community
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Выкладываю полезные файлы и плагины для сообщества
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://dribbble.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Dribbble — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/dribbble_logo.webp"
alt=""
width="1024"
height="1024"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Dribbble
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Красивые картинки с элементами интерфейсов
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://t.me/designnotpizza"
target="_blank"
rel="noopener noreferrer"
aria-label="Дизайн не пицца — открыть канал в Telegram"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/tg-channel-logo-design_not_pizza.webp"
alt=""
width="640"
height="640"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Дизайн не пицца
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мой канал про дизайн. Посты на тему продуктового дизайна и полезные ссылки
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://github.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/github_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
GitHub
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мои проекты и эксперименты с кодом
</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="activity" class="section section--surface" aria-labelledby="mini-projects-title" data-reveal>
<div class="section__inner">
<h2 id="mini-projects-title" class="section__title">Активность</h2>
<div
class="skills-intro skills-intro--chat section__prose"
data-skills-intro
role="region"
aria-labelledby="mini-projects-title"
>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__author">Николай М:</p>
<p class="skills-msg__text">
Люблю делать разные мини-проекты или делиться чем-то полезным с миром.
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
</div>
<ol id="mini-projects-list" class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="ai-dev/"
data-glass="false"
aria-label="AI-разработка — открыть страницу"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI-разработка</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Боты · MCP · плагины</li>
</ul>
<p class="mp-card__desc">Не только дизайн, а целые продукты: от API до деплоя</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/ai-assistant-figma/"
data-glass="false"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">ИИ помощник для любого тарифа редактора</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/card-columns-obsidian/"
data-glass="false"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Отображение карточек с заметками в стиле канбан доски</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/site-portfolio/"
data-glass="false"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Весь цикл создания сайта при помощи ИИ</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://www.figma.com/@mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Figma Community — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/figma_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Figma Community
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Выкладываю полезные файлы и плагины для сообщества
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://dribbble.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Dribbble — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/dribbble_logo.webp"
alt=""
width="1024"
height="1024"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Dribbble
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Красивые картинки с элементами интерфейсов
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://t.me/designnotpizza"
target="_blank"
rel="noopener noreferrer"
aria-label="Дизайн не пицца — открыть канал в Telegram"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/tg-channel-logo-design_not_pizza.webp"
alt=""
width="640"
height="640"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Дизайн не пицца
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мой канал про дизайн. Посты на тему продуктового дизайна и полезные ссылки
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://github.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="assets/misc/github_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
GitHub
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мои проекты и эксперименты с кодом
</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="experience" class="section section--surface" aria-labelledby="experience-title" data-reveal>
+6
View File
@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://mamadaevv.site/ai-dev/</loc>
<lastmod>2026-10-07</lastmod>
<xhtml:link rel="alternate" hreflang="x-default" href="https://mamadaevv.site/ai-dev/" />
<xhtml:link rel="alternate" hreflang="ru" href="https://mamadaevv.site/ai-dev/" />
</url>
<url>
<loc>https://mamadaevv.site/cases/ai-assistant-figma/</loc>
<lastmod>2026-09-26</lastmod>
+439 -428
View File
@@ -1,428 +1,439 @@
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/<lang>/.
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();
// Своя og-картинка для страницы основного AI Server-кейса.
const PER_PAGE_OG_IMAGE = {
"cases/ai-platform-primo-rpa": `${SITE_ORIGIN}/assets/og/og-primo-star.png`,
};
const titleRe = /<title>([\s\S]*?)<\/title>/i;
const descRe = /<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i;
function decodeEntities(str) {
return String(str)
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/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 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`
);
}
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;
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");
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 = PER_PAGE_OG_IMAGE[noSlug] || 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);
}
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();
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/<lang>/.
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`,
};
const titleRe = /<title>([\s\S]*?)<\/title>/i;
const descRe = /<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i;
function decodeEntities(str) {
return String(str)
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/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`
);
}
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;
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");
console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`);
console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`);
}
build();
+32
View File
@@ -0,0 +1,32 @@
// Генерация серых заглушек (16:10) для страницы ai-dev.
// Заглушки — намеренно нейтральный серый, без текста и градиентов.
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
const outDir = path.join(process.cwd(), "public", "assets", "ai-dev");
fs.mkdirSync(outDir, { recursive: true });
const W = 640;
const H = 400;
// Разные оттенки серого, чтобы карточки не выглядели одинаковыми при загрузке.
const shades = [
{ fill: "#2a2d31", label: "01" },
{ fill: "#31353a", label: "02" },
{ fill: "#25282c", label: "03" },
];
const tiles = await Promise.all(
shades.map(async ({ fill, label }) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
<rect width="${W}" height="${H}" fill="${fill}" />
</svg>`;
const file = path.join(outDir, `placeholder-${label}.webp`);
await sharp(Buffer.from(svg)).webp({ quality: 72 }).toFile(file);
return file;
}),
);
console.log(`Generated ${tiles.length} placeholders in ${outDir}`);
for (const f of tiles) console.log(" ", path.basename(f), fs.statSync(f).size, "bytes");
+351
View File
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark" data-radius="medium">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta
name="description"
content="Николай Мамадаев, AI-разработчик. Делаю целые продукты: Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты."
/>
<title>AI-разработка · Николай Мамадаев</title>
{{> head-links}}
{{> head-plyr}}
<style>
.ai-page {
max-width: 52rem;
margin: 0 auto;
}
.ai-page h2 {
margin: 2.25rem 0 0.85rem;
font-size: clamp(1.15rem, 2vw, 1.35rem);
font-weight: 700;
color: var(--text);
}
.ai-page .section__prose {
margin-bottom: 0.75rem;
color: var(--text);
}
.ai-page a:not(.btn) {
color: var(--accent);
}
/* Сетка карточек проектов: 2 колонки, схлопывается в одну на мобильном */
.ai-projects {
list-style: none;
margin: 1.25rem 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
}
.ai-projects > li {
margin: 0;
}
.ai-projects__media {
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--line);
margin-bottom: 0.7rem;
background: var(--surface);
}
.ai-projects__media img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.ai-projects__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.ai-projects__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.ai-projects__stack {
margin: 0.3rem 0 0;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.78rem;
color: var(--text-muted);
}
.ai-projects__desc {
margin: 0.45rem 0 0;
font-size: 0.95rem;
color: var(--text);
}
.ai-projects__links {
list-style: none;
margin: 0.6rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
font-size: 0.85rem;
}
/* Навыки — компактные чипы переиспользуют стили тегов карточек активности */
.ai-stack-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.ai-stack-list li {
margin: 0;
padding: 0.3rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
</head>
<body>
{{> header-case}}
{{> fab-stack}}
<main id="case-main" class="section section--tight">
<div class="section__inner ai-page">
<p class="hero__eyebrow">AI-разработка · идея, код, деплой</p>
<h1 class="case-page__h1">Делаю целые продукты</h1>
<p class="case-page__sub">Telegram-боты, MCP-серверы, плагины и утилиты. Схема, код, деплой</p>
<p class="section__prose">
Больше десяти лет я проектировал интерфейсы. Теперь довожу идеи до работающего
кода: бэкенд, база данных, деплой. Инфраструктура тоже моя часть работы.
В большинстве проектов код пишет ИИ-агент. Я задаю архитектуру, проверяю
результат и отвечаю за то, что продукт вообще работает.
</p>
<h2 id="roles">Что я делаю</h2>
<ul class="section__prose ai-stack-list">
<li>Продуктовая логика и API</li>
<li>Telegram-боты на aiogram 3</li>
<li>MCP-серверы</li>
<li>Плагины Figma и Obsidian</li>
<li>Автоматизация и агентные пайплайны</li>
</ul>
<h2 id="projects">Проекты</h2>
<ol class="ai-projects">
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-01.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Personal Assistant</h3>
</div>
<p class="ai-projects__stack">Python · aiogram 3 · PostgreSQL</p>
<p class="ai-projects__desc">
Личный ассистент в Telegram: напоминания, планирование дел и работа с LLM
в одном диалоге.
</p>
<ul class="ai-projects__links">
<li><a href="{{casesPrefix}}ai-platform-primo-rpa/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-02.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Image Gen Bot</h3>
</div>
<p class="ai-projects__stack">Python · aiogram 3 · Redis · PostgreSQL</p>
<p class="ai-projects__desc">
Бот для генерации изображений: баланс, реферальная программа, админ-логи
в отдельные топики супергруппы.
</p>
<ul class="ai-projects__links">
<li><a href="{{casesPrefix}}ai-platform-primo-rpa/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-03.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">MCP Image Server</h3>
</div>
<p class="ai-projects__stack">JavaScript · MCP · OpenRouter</p>
<p class="ai-projects__desc">
Собственный MCP-сервер для генерации картинок. Подключается к агентам
как инструмент.
</p>
<ul class="ai-projects__links">
<li><a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/mcp-img-gen" target="_blank" rel="noopener noreferrer">Код</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-01.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">AI assistant</h3>
</div>
<p class="ai-projects__stack">TypeScript · Figma Plugin API</p>
<p class="ai-projects__desc">
Плагин для Figma, который исполняет скрипты прямо в редакторе.
ИИ-ассистент для любого тарифа.
</p>
<ul class="ai-projects__links">
<li><a href="{{casesPrefix}}ai-assistant-figma/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-02.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Card Columns</h3>
</div>
<p class="ai-projects__stack">TypeScript · Obsidian API</p>
<p class="ai-projects__desc">
Вью для заметок в стиле канбан-доски: группировка по колонкам с фильтрами
по тегам.
</p>
<ul class="ai-projects__links">
<li><a href="{{casesPrefix}}card-columns-obsidian/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-03.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Hermes Desktop Plugins</h3>
</div>
<p class="ai-projects__stack">JavaScript · TypeScript · Electron</p>
<p class="ai-projects__desc">
Плагины для рабочего стола Hermes: чип баланса API, звук завершения задачи
и контроль трат.
</p>
<ul class="ai-projects__links">
<li><a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/unorouter-balance" target="_blank" rel="noopener noreferrer">Код</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-01.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">Social Post Writer</h3>
</div>
<p class="ai-projects__stack">Python · aiogram 3 · SQLite</p>
<p class="ai-projects__desc">
Бот, который превращает тезисы в готовый пост под нужную площадку и тон,
учитывая голос автора.
</p>
<ul class="ai-projects__links">
<li><a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/social-post-writer-bot" rel="noopener noreferrer">Код</a></li>
</ul>
</li>
<li>
<div class="ai-projects__media">
<img
src="{{assetPrefix}}assets/ai-dev/placeholder-02.webp"
alt=""
width="640"
height="400"
loading="lazy"
decoding="async"
/>
</div>
<div class="ai-projects__head">
<h3 class="ai-projects__title">AI Design Assistant</h3>
</div>
<p class="ai-projects__stack">HTML · CSS · GLSL · WebGL</p>
<p class="ai-projects__desc">
Этот сайт целиком: структура, дизайн-система, шейдеры, CI/CD.
Весь цикл создания вместе с ИИ-агентом.
</p>
<ul class="ai-projects__links">
<li><a href="{{casesPrefix}}site-portfolio/" target="_blank" rel="noopener noreferrer">Кейс</a></li>
</ul>
</li>
</ol>
<h2 id="stack">Стек</h2>
<ul class="section__prose ai-stack-list">
<li>Python</li>
<li>TypeScript</li>
<li>JavaScript</li>
<li>Go</li>
<li>HTML/CSS</li>
<li>GLSL</li>
<li>aiogram 3</li>
<li>FastAPI</li>
<li>PostgreSQL</li>
<li>Redis</li>
<li>SQLite</li>
<li>Docker</li>
<li>Coolify</li>
<li>Gitea</li>
<li>MCP</li>
</ul>
<h2 id="links">Где я это выкладываю</h2>
<p class="section__prose">
Код открыт: <a href="https://github.com/mamadaevv" target="_blank" rel="noopener noreferrer">GitHub</a>
и <a href="http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin" target="_blank" rel="noopener noreferrer">Gitea</a>.
Сервисы, которые работают прямо сейчас, живут на
<a href="https://secrets-4-me.dynv6.net" target="_blank" rel="noopener noreferrer">secrets-4-me</a>.
</p>
</div>
</main>
</body>
</html>
+207 -191
View File
@@ -1,191 +1,207 @@
<section id="activity" class="section section--surface" aria-labelledby="mini-projects-title" data-reveal>
<div class="section__inner">
<h2 id="mini-projects-title" class="section__title">Активность</h2>
<div
class="skills-intro skills-intro--chat section__prose"
data-skills-intro
role="region"
aria-labelledby="mini-projects-title"
>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__author">Николай М:</p>
<p class="skills-msg__text">
Люблю делать разные мини-проекты или делиться чем-то полезным с миром.
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
</div>
<ol id="mini-projects-list" class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/ai-assistant-figma/"
data-glass="false"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">ИИ помощник для любого тарифа редактора</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/card-columns-obsidian/"
data-glass="false"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Отображение карточек с заметками в стиле канбан доски</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/site-portfolio/"
data-glass="false"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Весь цикл создания сайта при помощи ИИ</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://www.figma.com/@mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Figma Community — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/figma_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Figma Community
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Выкладываю полезные файлы и плагины для сообщества
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://dribbble.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Dribbble — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/dribbble_logo.webp"
alt=""
width="1024"
height="1024"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Dribbble
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Красивые картинки с элементами интерфейсов
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://t.me/designnotpizza"
target="_blank"
rel="noopener noreferrer"
aria-label="Дизайн не пицца — открыть канал в Telegram"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/tg-channel-logo-design_not_pizza.webp"
alt=""
width="640"
height="640"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Дизайн не пицца
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мой канал про дизайн. Посты на тему продуктового дизайна и полезные ссылки
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://github.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/github_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
GitHub
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мои проекты и эксперименты с кодом
</p>
</div>
</a>
</li>
</ol>
</div>
</section>
<section id="activity" class="section section--surface" aria-labelledby="mini-projects-title" data-reveal>
<div class="section__inner">
<h2 id="mini-projects-title" class="section__title">Активность</h2>
<div
class="skills-intro skills-intro--chat section__prose"
data-skills-intro
role="region"
aria-labelledby="mini-projects-title"
>
<div class="skills-msg" data-skills-msg>
<div class="skills-msg__body">
<p class="skills-msg__author">Николай М:</p>
<p class="skills-msg__text">
Люблю делать разные мини-проекты или делиться чем-то полезным с миром.
</p>
<time class="skills-msg__time" datetime="" data-skills-msg-time></time>
</div>
</div>
</div>
<ol id="mini-projects-list" class="mini-projects-grid" data-mini-projects-list>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="ai-dev/"
data-glass="false"
aria-label="AI-разработка — открыть страницу"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI-разработка</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Боты · MCP · плагины</li>
</ul>
<p class="mp-card__desc">Не только дизайн, а целые продукты: от API до деплоя</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/ai-assistant-figma/"
data-glass="false"
aria-label="AI assistant — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">AI assistant</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Figma</li>
</ul>
<p class="mp-card__desc">ИИ помощник для любого тарифа редактора</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/card-columns-obsidian/"
data-glass="false"
aria-label="Card Columns — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Card Columns</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Плагин для Obsidian</li>
</ul>
<p class="mp-card__desc">Отображение карточек с заметками в стиле канбан доски</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--compact"
href="cases/site-portfolio/"
data-glass="false"
aria-label="Этот сайт — открыть кейс"
>
<div class="mp-card__body">
<h3 class="mp-card__title">Этот сайт</h3>
<ul class="mp-card__tags" aria-label="Тип проекта">
<li class="mp-card__tag">Разработка с AI IDE</li>
</ul>
<p class="mp-card__desc">Весь цикл создания сайта при помощи ИИ</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://www.figma.com/@mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Figma Community — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/figma_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Figma Community
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Выкладываю полезные файлы и плагины для сообщества
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://dribbble.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="Dribbble — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/dribbble_logo.webp"
alt=""
width="1024"
height="1024"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Dribbble
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Красивые картинки с элементами интерфейсов
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://t.me/designnotpizza"
target="_blank"
rel="noopener noreferrer"
aria-label="Дизайн не пицца — открыть канал в Telegram"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/tg-channel-logo-design_not_pizza.webp"
alt=""
width="640"
height="640"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
Дизайн не пицца
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мой канал про дизайн. Посты на тему продуктового дизайна и полезные ссылки
</p>
</div>
</a>
</li>
<li class="mini-projects-grid__item" data-mp-cells="2">
<a
class="mp-card mp-card--link mp-card--horizontal mp-card--hor-image"
href="https://github.com/mamadaevv"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub — открыть профиль"
>
<div class="mp-card__media" aria-hidden="true">
<img
class="mp-card__image"
src="{{assetPrefix}}assets/misc/github_logo.webp"
alt=""
width="800"
height="800"
loading="lazy"
decoding="async"
/>
</div>
<div class="mp-card__body">
<h3 class="mp-card__title">
GitHub
<span class="mp-card__ext" aria-hidden="true"></span>
</h3>
<p class="mp-card__desc">
Мои проекты и эксперименты с кодом
</p>
</div>
</a>
</li>
</ol>
</div>
</section>