Files
site-portfolio/scripts/build-pages.mjs
T

448 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/&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);
}
// 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();