feat: llms.txt — карта сайта для LLM-агентов + link-relation describedby в head

Генерируется в build-pages.mjs из тех же страниц, что и sitemap.xml, с ручными описаниями по каждому URL. Страницы-каталоги дизайн-системы (noindex) в файл не попадают.
This commit is contained in:
Nikolay Mamadaev committed 2026-10-07 14:01:09 +02:00
1 parent ee0dca11a2
commit f383ec350a
42 files changed
+177

No files matched your search

+98
View File
@@ -35,6 +35,39 @@ 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;
@@ -205,6 +238,65 @@ function buildSitemap(pageFiles) {
);
}
/** Генерация 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;
@@ -391,6 +483,8 @@ function build() {
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-сдвига) — для навигационных ссылок,
@@ -432,8 +526,12 @@ function build() {
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();