Files
site-portfolio/scripts/build-pages.mjs
T
root e02ce1c629 fix(i18n): keep en navigation within /en/ via pagePrefix
- build-pages.mjs: add pagePrefix (locale-root-relative, no en offset)
- use pagePrefix for nav links: logo, back-home, footer design-system/
  cookie-policy/, FAB #contact, cookie-notice link, cookie-policy body
  link, case-page #skills/#projects anchors (assetPrefix pointed to site
  root and leaked en pages to ru)
- docs/translation.md: document assetPrefix vs pagePrefix
2026-09-15 23:00:32 +02:00

206 lines
7.9 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";
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;
for (const loc of LOCALES) {
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);
const vars = baseVars(relNorm, depth);
// Префикс до КОРНЯ ЛОКАЛИ (без 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");
}
}
console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`);
}
build();