Files
Nikolay Mamadaev f383ec350a feat: llms.txt — карта сайта для LLM-агентов + link-relation describedby в head
Генерируется в build-pages.mjs из тех же страниц, что и sitemap.xml, с ручными описаниями по каждому URL. Страницы-каталоги дизайн-системы (noindex) в файл не попадают.
2026-10-07 14:01:09 +02:00

538 lines
24 KiB
JavaScript
Raw Permalink 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";
// 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`,
};
// По одной строке на страницу — попадает в llms.txt.
// Пишется от руки: description из <meta> у кейсов начинается со слов «Кейс:»
// и сам по себе ни о чём не говорит агенту. Правило: не повторять имя ссылки
// (оно уже стоит слева от двоеточия) и не писать «Кейс: …».
const LLMS_PAGE_NOTES = {
"": "Короткое описание, портфолио из кейсов и лента активности.",
resume: "Опыт работы, стек, проекты, навыки и контакты.",
"ai-dev": "Telegram-боты, MCP-серверы, плагины для Figma и Obsidian, desktop-утилиты.",
"cookie-policy": "Какие cookie и localStorage использует сайт.",
"cases/ai-assistant-figma": "Получает скрипты из Cursor и выполняет их в открытом файле.",
"cases/ai-platform-primo-rpa": "Фреймворк STAR для настройки проектов в AI Server.",
"cases/ai-platform-primo-rpa-v2": "Редизайн интерфейса AI Server.",
"cases/ai-platform-primo-rpa-2": "Черновик 2: OCR на документах, дообучение моделей, skills.",
"cases/card-columns-obsidian": "Канбан-доски по свойствам заметок, фильтры AND/OR, перенос карточек.",
"cases/lunacy": "Icons8: пользовательские сценарии, итерации интерфейса и скриншоты процесса.",
"cases/manu-game-maker": "Визуальная система и сложные пользовательские сценарии.",
"cases/mobile-clinic-app": "Запись к врачу, услуги, навигация, быстрые действия.",
"cases/obsidian-plugin-columns-view": "Дизайн и UX плагина для заметок.",
"cases/orc-automation-panel":
"Primo RPA: кураторство дизайна, переход на Ant Design и Storybook, токены в Figma.",
"cases/site-portfolio": "От no-code конструкторов до разработки в Cursor и Hermes Agent.",
"cases/task-distribution-concept": "Прозрачность процессов между командами.",
"cases/task-tracker": "Вкладки, фильтры, виды отображения, прототипы и пользовательские исследования.",
};
// Имя ссылки, когда заголовок страницы не отвечает на вопрос «что это».
// Заодно снимаем хвост «— кейс» / «— Case Study»: в секции «Кейсы» он лишний.
const LLMS_PAGE_NAMES = {
"cases/ai-platform-primo-rpa-v2": "AI Server в Primo RPA: редизайн",
"cases/ai-platform-primo-rpa-2": "AI Server в Primo RPA: черновик 2",
"cases/card-columns-obsidian": "Card Columns для Obsidian Bases",
};
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 === "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`
);
}
/** Генерация llms.txt: короткая карта сайта для LLM-агентов (спецификация llmstxt.org). */
function buildLlmsTxt(pageFiles) {
const entries = [];
for (const pagePath of pageFiles) {
const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/");
// Тот же slug, что и в sitePath(): index.html → "" (главная).
const slug = relNorm
.replace(/\.html$/, "")
.replace(/(^|\/)index$/, "")
.replace(/\/$/, "");
if (NOINDEX_PAGES.has(slug)) continue;
const { title } = extractMeta(readUtf8(pagePath));
// Имя ссылки: из <title> без хвоста с фамилией автора и без «— кейс»,
// но с явным переопределением там, где заголовок говорит только «AI Server».
const strippedTitle = title
? title
.replace(/\s*[·|—-]\s*Никол(ая|ай) Мамадаев\s*$/i, "")
.replace(/\s*[—–-]\s*(кейс|case study)\s*$/i, "")
.trim()
: "";
const name =
LLMS_PAGE_NAMES[slug] || (slug === "" ? "Главная" : strippedTitle || slug);
entries.push({ slug, name, url: canonicalFor(relNorm, DEFAULT_LOCALE) });
}
const cases = entries.filter((e) => e.slug.startsWith("cases/"));
const rest = entries.filter((e) => !e.slug.startsWith("cases/"));
/** Строка списка: описание дописывается, только если не повторяет имя ссылки. */
const line = (e) => {
const note = LLMS_PAGE_NOTES[e.slug] || "";
if (!note) return `- [${e.name}](${e.url})`;
// Описание, начинающееся именем ссылки, обрезаем: «Имя: Имя: …» агентам не помогает.
const prefix = `${e.name}: `;
const trimmed = note.startsWith(prefix) ? note.slice(prefix.length) : note;
return `- [${e.name}](${e.url}): ${trimmed}`;
};
const section = (heading, list) => `## ${heading}\n\n${list.map(line).join("\n")}\n`;
return (
`# ${SITE_NAME_RU}\n\n` +
`> Сайт-портфолио продуктового дизайнера Николая Мамадаева: кейсы UX/UI-проектов, резюме, ` +
`описание AI-разработки и контакты. Русский и английский, английские страницы лежат в /en/.\n\n` +
`Формат: [llms.txt](https://llmstxt.org/). Карта для поисковиков: ` +
`https://mamadaevv.site/sitemap.xml, правила для краулеров: https://mamadaevv.site/robots.txt.\n\n` +
`Ссылки ведут на HTML-страницы, отдельной markdown-версии у сайта нет. ` +
`Страницы-каталоги дизайн-системы (/cases/design-system/, /cases/template-elements-demo/, ` +
`/cases/mini-projects-showcase/) закрыты от индексации и сюда не попали.\n\n` +
section("Основные страницы", rest) +
"\n" +
section("Кейсы", cases) +
"\n" +
`## Контакты\n\n` +
`- [Telegram](https://t.me/mamadaevv): @mamadaevv\n` +
`- [Почта](mailto:nik@mamadaevv.site): nik@mamadaevv.site\n\n` +
`## Использование\n\n` +
`Цитировать кейсы и текст можно со ссылкой на страницу. Ссылка на сайт обязательна.\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;
// Link-relation v2 спецификации llms.txt: файл, покрывающий страницу.
vars.llmsUrl = `${SITE_ORIGIN}/llms.txt`;
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");
const llmsTxt = buildLlmsTxt(pageFiles);
fs.writeFileSync(path.join(outDir, "llms.txt"), llmsTxt, "utf8");
console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`);
console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`);
console.log(`Generated llms.txt (${llmsTxt.split("\n").filter((l) => l.startsWith("- [")).length} links)`);
}
build();