448 lines
19 KiB
JavaScript
448 lines
19 KiB
JavaScript
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";
|
||
|
||
// Liquid Glass: инлайн SVG displacement-фильтр, инъецируется во все страницы.
|
||
// PNG-карта — мелкий равномерный шум (см. статью Хабра), даёт «живое» стекло в Chromium.
|
||
// В Safari/Firefox url()-фильтр в backdrop-filter не рендерится — там остаётся чистый blur.
|
||
const LIQUID_GLASS_FILTER = `<svg class="liquid-glass-defs" aria-hidden="true" width="0" height="0" focusable="false" style="position:absolute;width:0;height:0;overflow:hidden;pointer-events:none">
|
||
<filter id="liquid-glass-displacement" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
||
<feImage href="{{LG_ASSET_PREFIX}}assets/liquid-glass/displacement.png" xlink:href="{{LG_ASSET_PREFIX}}assets/liquid-glass/displacement.png" preserveAspectRatio="none" result="lgMap"/>
|
||
<feDisplacementMap in="SourceGraphic" in2="lgMap" scale="14" xChannelSelector="R" yChannelSelector="G"/>
|
||
</filter>
|
||
<filter id="liquid-glass-displacement-btn" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
||
<feImage href="{{LG_ASSET_PREFIX}}assets/liquid-glass/displacement.png" xlink:href="{{LG_ASSET_PREFIX}}assets/liquid-glass/displacement.png" preserveAspectRatio="none" result="lgMapBtn"/>
|
||
<feDisplacementMap in="SourceGraphic" in2="lgMapBtn" scale="6" xChannelSelector="R" yChannelSelector="G"/>
|
||
</filter>
|
||
</svg>`;
|
||
|
||
// --- 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",
|
||
"cases/primo-ai-server-star",
|
||
]);
|
||
|
||
// Страницы, у которых пока нет перевода: собираются только в дефолтную локаль,
|
||
// чтобы /en/ не отдавал русскоязычный дубль и не попадал в sitemap.
|
||
const DEFAULT_LOCALE_ONLY_PAGES = new Set([
|
||
"cases/primo-ai-server-star",
|
||
]);
|
||
|
||
// Своя og-картинка для отдельной страницы (ключ — путь без .html).
|
||
const PER_PAGE_OG_IMAGE = {
|
||
"cases/primo-ai-server-star": `${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);
|
||
}
|
||
// Liquid Glass: инъецируем инлайн SVG displacement-фильтр сразу после <body>.
|
||
const lgSvg = LIQUID_GLASS_FILTER.replace(/{{LG_ASSET_PREFIX}}/g, vars.assetPrefix);
|
||
rendered = rendered.replace(/(<body[^>]*>)/, `$1\n ${lgSvg}\n`);
|
||
ensureDir(path.dirname(outPath));
|
||
fs.writeFileSync(outPath, rendered, "utf8");
|
||
}
|
||
}
|
||
|
||
const sitemapXml = buildSitemap(pageFiles);
|
||
fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml, "utf8");
|
||
|
||
console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`);
|
||
console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`);
|
||
}
|
||
|
||
build();
|