feat: страница /ai-dev/ — AI-разработка и проекты, карточка в Активности
This commit is contained in:
1 parent
39dad71bd1
commit
52485a17c4
10 files changed
+1895
-810
No files matched your search
+439
-428
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function extractMeta(html) {
|
||||
const t = html.match(titleRe);
|
||||
const d = html.match(descRe);
|
||||
return {
|
||||
title: t ? decodeEntities(t[1]).trim() : "",
|
||||
description: d ? decodeEntities(d[1]).trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Путь страницы на сайте (без ведущего слэша, с закрывающим). */
|
||||
function sitePath(relNorm, loc) {
|
||||
const base = relNorm
|
||||
.replace(/\.html$/, "")
|
||||
.replace(/(^|\/)index$/, "/");
|
||||
const norm = relNorm.startsWith("cases/")
|
||||
? `cases/${path.basename(relNorm, ".html")}/`
|
||||
: base === "resume" ? "resume/"
|
||||
: base === "cookie-policy" ? "cookie-policy/"
|
||||
: base === "" || base === "/" ? ""
|
||||
: `${base.replace(/\/$/, "")}/`;
|
||||
return loc === DEFAULT_LOCALE ? norm : `${loc}/${norm}`;
|
||||
}
|
||||
|
||||
/** Канонический URL страницы. */
|
||||
function canonicalFor(relNorm, loc) {
|
||||
const p = sitePath(relNorm, loc);
|
||||
return `${SITE_ORIGIN}/${p}`;
|
||||
}
|
||||
|
||||
/** Дата изменения страницы (mtime исходника) в формате YYYY-MM-DD. */
|
||||
function lastModFor(relNorm) {
|
||||
const src = path.join(srcPagesDir, relNorm);
|
||||
const enSrc = src.replace(/\.html$/, ".en.html");
|
||||
let mtime = 0;
|
||||
if (fs.existsSync(src)) mtime = fs.statSync(src).mtimeMs;
|
||||
if (fs.existsSync(enSrc)) mtime = Math.max(mtime, fs.statSync(enSrc).mtimeMs);
|
||||
return new Date(mtime || Date.now()).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** JSON-LD для страницы: Person+WebSite на главной, CreativeWork на кейсах, BreadcrumbList везде. */
|
||||
function buildJsonLd(relNorm, loc, meta, canonical) {
|
||||
const isRu = loc === DEFAULT_LOCALE;
|
||||
const authorName = isRu ? AUTHOR_NAME_RU : AUTHOR_NAME_EN;
|
||||
const siteName = isRu ? SITE_NAME_RU : SITE_NAME_EN;
|
||||
const graph = [];
|
||||
const personId = `${SITE_ORIGIN}/#person`;
|
||||
const websiteId = `${SITE_ORIGIN}/#website`;
|
||||
|
||||
const person = {
|
||||
"@type": "Person",
|
||||
"@id": personId,
|
||||
name: authorName,
|
||||
url: SITE_ORIGIN,
|
||||
jobTitle: isRu ? "Продуктовый дизайнер" : "Product Designer",
|
||||
description: isRu
|
||||
? "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы."
|
||||
: "Product designer with 10+ years of experience. Designing digital products, interfaces and design systems.",
|
||||
knowsAbout: isRu
|
||||
? ["UX/UI-дизайн", "продуктовый дизайн", "дизайн-системы", "прототипирование", "Figma", "no-code"]
|
||||
: ["UX/UI design", "product design", "design systems", "prototyping", "Figma", "no-code"],
|
||||
sameAs: ["https://t.me/mamadaevv", "https://vk.com/mamadaevv", "https://github.com/mamadaevv"],
|
||||
};
|
||||
graph.push(person);
|
||||
|
||||
graph.push({
|
||||
"@type": "WebSite",
|
||||
"@id": websiteId,
|
||||
url: `${SITE_ORIGIN}/`,
|
||||
name: siteName,
|
||||
inLanguage: loc,
|
||||
author: { "@id": personId },
|
||||
publisher: { "@id": personId },
|
||||
});
|
||||
|
||||
const isCase = relNorm.startsWith("cases/");
|
||||
const pageNode = {
|
||||
"@type": isCase ? "CreativeWork" : "WebPage",
|
||||
"@id": `${canonical}#page`,
|
||||
url: canonical,
|
||||
name: meta.title,
|
||||
description: meta.description,
|
||||
isPartOf: { "@id": websiteId },
|
||||
author: { "@id": personId },
|
||||
inLanguage: loc,
|
||||
};
|
||||
if (isCase && !NOINDEX_PAGES.has(relNorm.replace(/\.html$/, ""))) {
|
||||
pageNode.creator = { "@id": personId };
|
||||
pageNode.datePublished = lastModFor(relNorm);
|
||||
pageNode.about = isRu
|
||||
? "UX/UI-дизайн и проектирование интерфейсов"
|
||||
: "UX/UI design and interface design";
|
||||
}
|
||||
graph.push(pageNode);
|
||||
|
||||
// Хлебные крошки для вложенных страниц.
|
||||
const parts = sitePath(relNorm, loc).split("/").filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
const crumbs = [{ "@type": "ListItem", position: 1, name: siteName, item: `${SITE_ORIGIN}/` }];
|
||||
let acc = SITE_ORIGIN;
|
||||
let pos = 2;
|
||||
for (const part of parts) {
|
||||
if (part === loc) continue;
|
||||
acc += `/${part}`;
|
||||
crumbs.push({
|
||||
"@type": "ListItem",
|
||||
position: pos++,
|
||||
name: part === "cases" ? (isRu ? "Кейсы" : "Cases") : meta.title || part,
|
||||
item: `${acc}/`,
|
||||
});
|
||||
}
|
||||
if (crumbs.length > 1) {
|
||||
graph.push({
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": `${canonical}#breadcrumbs`,
|
||||
itemListElement: crumbs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
};
|
||||
}
|
||||
|
||||
/** Генерация sitemap.xml с hreflang-альтернатами. */
|
||||
function buildSitemap(pageFiles) {
|
||||
const urls = [];
|
||||
const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i");
|
||||
for (const pagePath of pageFiles) {
|
||||
const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/");
|
||||
const noSlug = relNorm.replace(/\.html$/, "");
|
||||
if (NOINDEX_PAGES.has(noSlug)) continue;
|
||||
const lastmod = lastModFor(relNorm);
|
||||
const locUrl = canonicalFor(relNorm, DEFAULT_LOCALE);
|
||||
const 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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function extractMeta(html) {
|
||||
const t = html.match(titleRe);
|
||||
const d = html.match(descRe);
|
||||
return {
|
||||
title: t ? decodeEntities(t[1]).trim() : "",
|
||||
description: d ? decodeEntities(d[1]).trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Путь страницы на сайте (без ведущего слэша, с закрывающим). */
|
||||
function sitePath(relNorm, loc) {
|
||||
const base = relNorm
|
||||
.replace(/\.html$/, "")
|
||||
.replace(/(^|\/)index$/, "/");
|
||||
const norm = relNorm.startsWith("cases/")
|
||||
? `cases/${path.basename(relNorm, ".html")}/`
|
||||
: base === "resume" ? "resume/"
|
||||
: base === "cookie-policy" ? "cookie-policy/"
|
||||
: base === "ai-dev" ? "ai-dev/"
|
||||
: base === "" || base === "/" ? ""
|
||||
: `${base.replace(/\/$/, "")}/`;
|
||||
return loc === DEFAULT_LOCALE ? norm : `${loc}/${norm}`;
|
||||
}
|
||||
|
||||
/** Канонический URL страницы. */
|
||||
function canonicalFor(relNorm, loc) {
|
||||
const p = sitePath(relNorm, loc);
|
||||
return `${SITE_ORIGIN}/${p}`;
|
||||
}
|
||||
|
||||
/** Дата изменения страницы (mtime исходника) в формате YYYY-MM-DD. */
|
||||
function lastModFor(relNorm) {
|
||||
const src = path.join(srcPagesDir, relNorm);
|
||||
const enSrc = src.replace(/\.html$/, ".en.html");
|
||||
let mtime = 0;
|
||||
if (fs.existsSync(src)) mtime = fs.statSync(src).mtimeMs;
|
||||
if (fs.existsSync(enSrc)) mtime = Math.max(mtime, fs.statSync(enSrc).mtimeMs);
|
||||
return new Date(mtime || Date.now()).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** JSON-LD для страницы: Person+WebSite на главной, CreativeWork на кейсах, BreadcrumbList везде. */
|
||||
function buildJsonLd(relNorm, loc, meta, canonical) {
|
||||
const isRu = loc === DEFAULT_LOCALE;
|
||||
const authorName = isRu ? AUTHOR_NAME_RU : AUTHOR_NAME_EN;
|
||||
const siteName = isRu ? SITE_NAME_RU : SITE_NAME_EN;
|
||||
const graph = [];
|
||||
const personId = `${SITE_ORIGIN}/#person`;
|
||||
const websiteId = `${SITE_ORIGIN}/#website`;
|
||||
|
||||
const person = {
|
||||
"@type": "Person",
|
||||
"@id": personId,
|
||||
name: authorName,
|
||||
url: SITE_ORIGIN,
|
||||
jobTitle: isRu ? "Продуктовый дизайнер" : "Product Designer",
|
||||
description: isRu
|
||||
? "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы."
|
||||
: "Product designer with 10+ years of experience. Designing digital products, interfaces and design systems.",
|
||||
knowsAbout: isRu
|
||||
? ["UX/UI-дизайн", "продуктовый дизайн", "дизайн-системы", "прототипирование", "Figma", "no-code"]
|
||||
: ["UX/UI design", "product design", "design systems", "prototyping", "Figma", "no-code"],
|
||||
sameAs: ["https://t.me/mamadaevv", "https://vk.com/mamadaevv", "https://github.com/mamadaevv"],
|
||||
};
|
||||
graph.push(person);
|
||||
|
||||
graph.push({
|
||||
"@type": "WebSite",
|
||||
"@id": websiteId,
|
||||
url: `${SITE_ORIGIN}/`,
|
||||
name: siteName,
|
||||
inLanguage: loc,
|
||||
author: { "@id": personId },
|
||||
publisher: { "@id": personId },
|
||||
});
|
||||
|
||||
const isCase = relNorm.startsWith("cases/");
|
||||
const pageNode = {
|
||||
"@type": isCase ? "CreativeWork" : "WebPage",
|
||||
"@id": `${canonical}#page`,
|
||||
url: canonical,
|
||||
name: meta.title,
|
||||
description: meta.description,
|
||||
isPartOf: { "@id": websiteId },
|
||||
author: { "@id": personId },
|
||||
inLanguage: loc,
|
||||
};
|
||||
if (isCase && !NOINDEX_PAGES.has(relNorm.replace(/\.html$/, ""))) {
|
||||
pageNode.creator = { "@id": personId };
|
||||
pageNode.datePublished = lastModFor(relNorm);
|
||||
pageNode.about = isRu
|
||||
? "UX/UI-дизайн и проектирование интерфейсов"
|
||||
: "UX/UI design and interface design";
|
||||
}
|
||||
graph.push(pageNode);
|
||||
|
||||
// Хлебные крошки для вложенных страниц.
|
||||
const parts = sitePath(relNorm, loc).split("/").filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
const crumbs = [{ "@type": "ListItem", position: 1, name: siteName, item: `${SITE_ORIGIN}/` }];
|
||||
let acc = SITE_ORIGIN;
|
||||
let pos = 2;
|
||||
for (const part of parts) {
|
||||
if (part === loc) continue;
|
||||
acc += `/${part}`;
|
||||
crumbs.push({
|
||||
"@type": "ListItem",
|
||||
position: pos++,
|
||||
name: part === "cases" ? (isRu ? "Кейсы" : "Cases") : meta.title || part,
|
||||
item: `${acc}/`,
|
||||
});
|
||||
}
|
||||
if (crumbs.length > 1) {
|
||||
graph.push({
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": `${canonical}#breadcrumbs`,
|
||||
itemListElement: crumbs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
};
|
||||
}
|
||||
|
||||
/** Генерация sitemap.xml с hreflang-альтернатами. */
|
||||
function buildSitemap(pageFiles) {
|
||||
const urls = [];
|
||||
const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i");
|
||||
for (const pagePath of pageFiles) {
|
||||
const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/");
|
||||
const noSlug = relNorm.replace(/\.html$/, "");
|
||||
if (NOINDEX_PAGES.has(noSlug)) continue;
|
||||
const lastmod = lastModFor(relNorm);
|
||||
const locUrl = canonicalFor(relNorm, DEFAULT_LOCALE);
|
||||
const buildLocales = DEFAULT_LOCALE_ONLY_PAGES.has(noSlug) ? [DEFAULT_LOCALE] : LOCALES;
|
||||
const alternates = buildLocales.map(
|
||||
(loc) =>
|
||||
` <xhtml:link rel="alternate" hreflang="${loc}" href="${canonicalFor(relNorm, loc)}" />`
|
||||
);
|
||||
urls.push(
|
||||
` <url>\n <loc>${locUrl}</loc>\n <lastmod>${lastmod}</lastmod>\n` +
|
||||
` <xhtml:link rel="alternate" hreflang="x-default" href="${locUrl}" />\n` +
|
||||
alternates.join("\n") +
|
||||
`\n </url>`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` +
|
||||
` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
|
||||
urls.join("\n") +
|
||||
`\n</urlset>\n`
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user