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

+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");