- 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
206 lines
7.9 KiB
JavaScript
206 lines
7.9 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";
|
||
|
||
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();
|