diff --git a/apps/docs/e2e.playwright-spec.ts b/apps/docs/e2e.playwright-spec.ts
index d871f08ced..80061d15e1 100644
--- a/apps/docs/e2e.playwright-spec.ts
+++ b/apps/docs/e2e.playwright-spec.ts
@@ -27,7 +27,7 @@ test.describe('docs app', () => {
await page.goto('/en');
await waitForHydration(page);
- await expect(page).toHaveTitle('Koobiq');
+ await expect(page).toHaveTitle('Koobiq — Angular design system');
await expect(page.locator('.docs-welcome__header')).toContainText('Koobiq design system');
});
@@ -45,8 +45,11 @@ test.describe('docs app', () => {
await page.goto('/en/components/alert/overview');
await waitForHydration(page);
- await expect(page).toHaveTitle('Alert · Koobiq');
- await expect(page.locator('meta[name="description"]')).toHaveAttribute('content', /Koobiq/);
+ await expect(page).toHaveTitle('Alert — Overview · Koobiq');
+ await expect(page.locator('meta[name="description"]')).toHaveAttribute(
+ 'content',
+ 'Shows important information on a page. Can contain a hint, signal a status change, or indicate a problem.'
+ );
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://koobiq.io/en/components/alert/overview'
@@ -61,9 +64,11 @@ test.describe('docs app', () => {
await page.getByRole('tab', { name: 'API', exact: true }).click();
await expect(page).toHaveURL(/\/en\/components\/select\/api$/);
+ await expect(page).toHaveTitle('Select — API · Koobiq');
await page.getByRole('tab', { name: 'Examples', exact: true }).click();
await expect(page).toHaveURL(/\/en\/components\/select\/examples$/);
+ await expect(page).toHaveTitle('Select — Examples · Koobiq');
});
test('shows the source of a live example', async ({ page }) => {
@@ -146,3 +151,30 @@ test.describe('docs app', () => {
await expect(cell).toHaveAttribute('tabindex', '0');
});
});
+
+test.describe('prerendered SEO metadata', () => {
+ test.use({ javaScriptEnabled: false });
+
+ test('is present in the initial HTML without hydration', async ({ page }) => {
+ await page.goto('/en/components/alert/overview');
+
+ await expect(page).toHaveTitle('Alert — Overview · Koobiq');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ await expect(page.locator('meta[name="description"]')).toHaveAttribute(
+ 'content',
+ 'Shows important information on a page. Can contain a hint, signal a status change, or indicate a problem.'
+ );
+ await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
+ 'content',
+ 'https://koobiq.io/assets/images/welcome/alerts-light.png'
+ );
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+ 'href',
+ 'https://koobiq.io/en/components/alert/overview'
+ );
+ await expect(page.locator('link[rel="alternate"][hreflang="ru"]')).toHaveAttribute(
+ 'href',
+ 'https://koobiq.io/ru/components/alert/overview'
+ );
+ });
+});
diff --git a/apps/docs/src/app/components/component-viewer/component-viewer.template.html b/apps/docs/src/app/components/component-viewer/component-viewer.template.html
index 84f19362e8..92bf19a75f 100644
--- a/apps/docs/src/app/components/component-viewer/component-viewer.template.html
+++ b/apps/docs/src/app/components/component-viewer/component-viewer.template.html
@@ -12,7 +12,9 @@
{{ t('overviewTab') }}
@if (structureItem.hasApi) {
- API
+
+ {{ t('apiTab') }}
+
}
@if (structureItem.hasExamples) {
diff --git a/apps/docs/src/app/page-paths.spec.ts b/apps/docs/src/app/page-paths.spec.ts
new file mode 100644
index 0000000000..2abe0a7278
--- /dev/null
+++ b/apps/docs/src/app/page-paths.spec.ts
@@ -0,0 +1,38 @@
+import { readFileSync } from 'fs';
+import { join } from 'path';
+import { DOCS_SUPPORTED_LOCALES } from './constants/locale';
+import { docsGetIndexablePagePaths } from './page-paths';
+
+describe(docsGetIndexablePagePaths.name, () => {
+ it('contains welcome, overview, API, examples and icons pages without duplicates', () => {
+ const paths = docsGetIndexablePagePaths();
+
+ expect(paths).toContain('');
+ expect(paths).toContain('components/alert/overview');
+ expect(paths).toContain('components/alert/api');
+ expect(paths).toContain('components/select/examples');
+ expect(paths).toContain('icons');
+ expect(new Set(paths).size).toBe(paths.length);
+ });
+
+ it('stays synchronized with the committed prerender route registry', () => {
+ const expectedRoutes = DOCS_SUPPORTED_LOCALES.flatMap((locale) =>
+ docsGetIndexablePagePaths().map((path) => `/${locale}${path ? `/${path}` : ''}`)
+ );
+ const prerenderRoutes = readFileSync(join(process.cwd(), 'apps/docs/src/prerender-routes.txt'), 'utf8')
+ .trim()
+ .split('\n');
+
+ expect(prerenderRoutes).toEqual(expectedRoutes);
+ });
+
+ it('stays synchronized with the committed sitemap', () => {
+ const expectedUrls = docsGetIndexablePagePaths().flatMap((path) =>
+ DOCS_SUPPORTED_LOCALES.map((locale) => `https://koobiq.io/${locale}${path ? `/${path}` : ''}`)
+ );
+ const sitemap = readFileSync(join(process.cwd(), 'apps/docs/src/sitemap.xml'), 'utf8');
+ const sitemapUrls = Array.from(sitemap.matchAll(/(.*?)<\/loc>/g), ([, url]) => url);
+
+ expect(sitemapUrls).toEqual(expectedUrls);
+ });
+});
diff --git a/apps/docs/src/app/page-paths.ts b/apps/docs/src/app/page-paths.ts
new file mode 100644
index 0000000000..3d7ff80fac
--- /dev/null
+++ b/apps/docs/src/app/page-paths.ts
@@ -0,0 +1,28 @@
+import {
+ docsGetItems,
+ DocsStructureCategoryId,
+ DocsStructureItemId,
+ DocsStructureItemTab,
+ DocsStructureTokensTab
+} from './structure';
+
+/**
+ * Returns every localized-content path that should be prerendered and indexed, without a locale
+ * prefix. An empty string represents the localized welcome page.
+ */
+export const docsGetIndexablePagePaths = (): string[] => {
+ const paths = docsGetItems().flatMap(({ categoryId, id, hasApi, hasExamples }) => {
+ if (id === DocsStructureItemId.DesignTokens) {
+ return Object.values(DocsStructureTokensTab).map((tab) => `${categoryId}/${id}/${tab}`);
+ }
+
+ const tabs = [`${categoryId}/${id}/${DocsStructureItemTab.Overview}`];
+
+ if (hasApi) tabs.push(`${categoryId}/${id}/${DocsStructureItemTab.Api}`);
+ if (hasExamples) tabs.push(`${categoryId}/${id}/${DocsStructureItemTab.Examples}`);
+
+ return tabs;
+ });
+
+ return ['', ...paths, DocsStructureCategoryId.Icons];
+};
diff --git a/apps/docs/src/app/seo-descriptions.ts b/apps/docs/src/app/seo-descriptions.ts
new file mode 100644
index 0000000000..b67be7081a
--- /dev/null
+++ b/apps/docs/src/app/seo-descriptions.ts
@@ -0,0 +1,350 @@
+/**
+ * NOTE! Do not edit manually. Generated from the first paragraph of localized overview Markdown.
+ * Run `yarn run build:docs-content` to update.
+ */
+export const DOCS_SEO_DESCRIPTIONS = {
+ accordion: {
+ en: 'An accordion is an interactive UI element that allows users to expand and collapse individual blocks of information on demand, organizing them into compact sections.',
+ ru: 'Аккордеон (accordion) — это интерактивный элемент интерфейса, позволяющий пользователю раскрывать по требованию отдельные блоки информации, организованные в компактные секции.'
+ },
+ 'actions-panel': {
+ en: 'KbqActionsPanel - popup panel with bulk actions on selected objects.',
+ ru: 'KbqActionsPanel — всплывающая панель с массовыми действиями над выбранными объектами.'
+ },
+ 'ag-grid': {
+ en: 'AG Grid is designed for working with large tables. The component supports sorting, virtual scrolling, resizing, and reordering of columns. It is based on the library ag-grid-angular.',
+ ru: 'AG grid предназначен для работы с большими таблицами. Компонент поддерживает сортировку, виртуальную прокрутку, изменение ширины и порядка колонок. Основан на библиотеке ag-grid-angular.'
+ },
+ alert: {
+ en: 'Shows important information on a page. Can contain a hint, signal a status change, or indicate a problem.',
+ ru: 'Показывает важную информацию на странице. Может содержать подсказку, сигнализировать об изменении статуса или наличии проблемы.'
+ },
+ 'angular-20-breaking-changes': {
+ en: 'These changes are part of Koobiq v20.0.0 (2026-05-13) — the move to Angular 20. The step-by-step upgrade scenario is described in the migration guide; below is the full list of breaking changes.',
+ ru: 'Эти изменения вошли в Koobiq v20.0.0 (2026-05-13) — переход на Angular 20. Пошаговый сценарий обновления описан в гайде по миграции; ниже — полный список ломающих изменений.'
+ },
+ 'app-switcher': {
+ en: 'A menu for switching between applications and platforms.',
+ ru: 'Меню для переключения между приложениями и площадками.'
+ },
+ autocomplete: {
+ en: 'You can place auxiliary elements in the footer: buttons, links, hints.',
+ ru: 'В нижнем колонтитуле можно разместить вспомогательные элементы: кнопки, ссылки, подсказки.'
+ },
+ badge: {
+ en: 'A badge is used to highlight the status, count, or other important characteristics of an object.',
+ ru: 'Бейдж используется для выделения статуса, количества или других важных характеристик объекта.'
+ },
+ breadcrumbs: {
+ en: 'Breadcrumbs are a navigation element that helps users easily orient themselves on a website and understand their current location relative to the main page.',
+ ru: 'Хлебные крошки – это элемент навигации, который позволяет пользователю легко ориентироваться на сайте и понимать, где он находится в данный момент относительно главной страницы.'
+ },
+ button: {
+ en: 'koobiq buttons are available using native button or a elements.',
+ ru: 'Используется для запуска действий.'
+ },
+ 'button-group': {
+ en: 'Button Group combines multiple buttons to emphasize their close relationship.',
+ ru: 'Button Group объединяет несколько кнопок, когда важно подчеркнуть их связь.'
+ },
+ 'button-toggle': {
+ en: 'A button group is suitable for choosing from a list of 3 to 5 items when they fit in a single row without wrapping.',
+ ru: 'Группа кнопок подходит для выбора из списка от 3 до 5 элементов, когда они помещаются в строку без переносов.'
+ },
+ checkbox: {
+ en: 'Dual-state is applied using the boolean attribute [checked] to show whether the checkbox is checked or not.',
+ ru: 'Dual-state применяется с использованием логического атрибута [checked], чтобы показать, установлен checkbox или нет.'
+ },
+ 'clamped-list': {
+ en: 'By default, 10 items are shown and the rest are hidden. If the hidden portion contains fewer than 6 items, the full list is displayed. These parameters can be changed if needed.',
+ ru: 'По умолчанию показывается 10 элементов, остальные скрываются. Если в скрытой части меньше 6 элементов, то показываем весь список. Эти параметры при необходимости можно изменить.'
+ },
+ 'clamped-text': {
+ en: 'The Clamped Text component helps display long text neatly. When collapsed, it shows only a specified number of lines; when expanded, it reveals the full text.',
+ ru: 'Компонент Clamped Text помогает аккуратно показать длинный текст. В свернутом виде он оставляет только заданное число строк, а при разворачивании показывает весь текст.'
+ },
+ 'code-block': {
+ en: 'kbq-code-block is a component that displays reformatted text content with syntax highlighting.',
+ ru: 'kbq-code-block - компонент который показывает переформатированный текстовый контент и подсвечивает синтаксис.'
+ },
+ 'content-panel': {
+ en: 'KbqContentPanel - a slide-out side panel that shifts adjacent content. Often used to implement a quick preview mode for entities from a table.',
+ ru: 'KbqContentPanel - выезжающая сбоку панель, которая сдвигает соседний контент. Часто используется, чтобы реализовать режим быстрого просмотра сущности из таблицы.'
+ },
+ core: {
+ en: 'The core module is a foundational part of the Koobiq design system. It provides essential utilities, services, and components used across other modules in the system.',
+ ru: 'Модуль core является фундаментальной частью дизайн-системы Koobiq. Он предоставляет базовые утилиты, сервисы и компоненты, необходимые для построения и функционирования остальных модулей системы.'
+ },
+ 'date-formatter': {
+ en: 'DateFormatter is a unified system for formatting dates and times. It keeps the presentation consistent across the whole application and follows the corporate standards.',
+ ru: 'DateFormatter — унифицированная система форматирования дат и времени. Она обеспечивает единообразное отображение во всех частях приложения и соответствует корпоративным стандартам.'
+ },
+ datepicker: {
+ en: 'A date field (datepicker) is a special input field with a widget for selecting a date.',
+ ru: 'Поле с датой (дейтпикер) — специальное поле с виджетом для выбора даты.'
+ },
+ divider: {
+ en: 'kbq-divider is a component that allows for koobiq styling of a line separator with various orientation options.',
+ ru: 'kbq-divider — это компонент, который используется для разделительной линии с различными вариантами ориентации.'
+ },
+ dl: {
+ en: 'Description list displays term-description pairs in adaptive, horizontal, or vertical layouts.',
+ ru: 'Description list отображает пары терминов и описаний в адаптивном, горизонтальном или вертикальном формате.'
+ },
+ dropdown: {
+ en: 'Add the progress attribute to kbq-dropdown-item to show a loading shimmer on the item. It can be combined with disabled to also prevent interaction while loading.',
+ ru: 'Добавьте атрибут progress к kbq-dropdown-item, чтобы отобразить анимацию загрузки на элементе. Его можно совмещать с disabled, чтобы дополнительно запретить взаимодействие с элементом во время…'
+ },
+ 'dynamic-translation': {
+ en: 'KbqDynamicTranslation — component for embedding custom components into translatable strings.',
+ ru: 'KbqDynamicTranslation — компонент для встраивания пользовательских компонентов в переводимые строки.'
+ },
+ 'ellipsis-center': {
+ en: '🚧 Documentation in progress 🚧',
+ ru: '🚧 Документация в процессе написания 🚧'
+ },
+ 'empty-state': {
+ en: 'A placeholder component for empty states or error messages. The border is not part of the component; it is shown only for illustration purposes.',
+ ru: 'Компонент-заглушка для пустых состояний или сообщений об ошибке. Рамка не является частью компонента, а показана только для иллюстраций к статье.'
+ },
+ 'file-upload': {
+ en: 'Allows the user to upload files to the product.',
+ ru: 'Позволяет пользователю загрузить файлы в продукт.'
+ },
+ 'filesize-formatter': {
+ en: 'Filesize formatter automatically converts values into kilobytes, megabytes, gigabytes, etc., taking localization into account. Rounding can also be customized.',
+ ru: 'Filesize formatter нужен для удобного форматирования размера файлов (или любых числовых значений в байтах) в читаемый вид, понятный человеку. Он автоматически конвертирует в килобайты, мегабайты,…'
+ },
+ 'filter-bar': {
+ en: 'A composite component for filtering data in a table or list.',
+ ru: 'Составной компонент для фильтрации данных в таблице или списке.'
+ },
+ flag: {
+ en: 'Use the country-flag-icons package to show a small country flag in your product:',
+ ru: 'Компонент kbq-flag показывает флаг страны. Он не содержит изображений, а декорирует переданное содержимое: управляет формой и тенью, обеспечивает доступность. Поэтому флаг выглядит одинаково в…'
+ },
+ 'form-field': {
+ en: 'kbq-form-field is a component used to create forms and input fields with support for styling and additional features.',
+ ru: 'kbq-form-field - это компонент, который используется для создания форм и полей ввода с поддержкой стилизации и дополнительных функций.'
+ },
+ forms: {
+ en: 'By default, labels occupy one out of four columns with a 16 px gutter. You can also set a different size, including a fixed width.',
+ ru: 'По умолчанию лейблы занимают одну колонку из четырёх с межколонником 16 px. Можно задать и другой размер, в том числе фиксированный.'
+ },
+ highlight: {
+ en: 'Highlighting matches helps users quickly understand why a result is relevant and speeds up navigation through lists. Depending on the context, one of two styles is used.',
+ ru: 'Выделение совпадений позволяет пользователю быстро понять, почему результат релевантен, и ускоряет навигацию по спискам. В зависимости от контекста применяется один из двух способов оформления.'
+ },
+ icon: {
+ en: 'Uses @koobiq/icons CSS font. No providers needed — works out of the box after adding the stylesheet.',
+ ru: 'Использует CSS-шрифт @koobiq/icons. Провайдеры не требуются — достаточно подключить стили.'
+ },
+ 'icon-button': {
+ en: 'For icon buttons (clickable icons).',
+ ru: 'Для иконок-кнопок (кликабельных иконок).'
+ },
+ 'icon-item': {
+ en: 'A backing icon can be used instead of an illustration where you want to attract attention with graphics.',
+ ru: 'Иконку на подложке можно использовать вместо иллюстрации там, где нужно привлечь внимание с помощью графики'
+ },
+ 'inline-edit': {
+ en: 'Inline editing can replace a traditional form when the user only needs to update a few fields among many available parameters.',
+ ru: 'Инлайн-редактирование может заменить форму, когда пользователю нужно изменить всего несколько полей среди большого числа параметров.'
+ },
+ input: {
+ en: 'This is a special field for entering numbers only. You can type digits here, and the system will automatically format them in a user-friendly way (for example, adding thousands separators).',
+ ru: 'Это специальное поле для ввода только чисел. Вы можете вводить сюда цифры, а система автоматически будет форматировать их в удобном для пользователя виде (например, добавлять разделители тысяч).'
+ },
+ installation: {
+ en: 'This guide describes how to set up an Angular project to use @koobiq/components.',
+ ru: 'В этом руководстве описана настройка Angular-проекта для использования @koobiq/components.'
+ },
+ 'layout-flex': {
+ en: 'Layout flex provides Koobiq CSS classes for arranging, aligning, and ordering elements in flex containers.',
+ ru: 'Layout flex — набор CSS-классов Koobiq для компоновки, выравнивания и упорядочивания элементов во flex-контейнерах.'
+ },
+ link: {
+ en: 'A link connects web pages or acts as a lighter alternative to a button.',
+ ru: 'Ссылка связывает веб-страницы или выступает как более легкий аналог кнопки.'
+ },
+ list: {
+ en: 'List supports groups, single or multiple selection, keyboard navigation, and virtual scrolling.',
+ ru: 'List отображает списки с группами, одиночным или множественным выбором, клавиатурной навигацией и виртуальной прокруткой.'
+ },
+ 'loader-overlay': {
+ en: 'The loading overlay displays an ongoing process within a block.',
+ ru: 'Оверлей загрузки показывает происходящий в нем процесс.'
+ },
+ markdown: {
+ en: 'KbqMarkdown - component that allows converting text written in Markdown markup language into HTML.',
+ ru: 'KbqMarkdown - компонент, который позволяет преобразовывать текст, написанный на языке разметки Markdown, в HTML.'
+ },
+ migration: {
+ en: 'New versions include improvements but also contain breaking changes; they must be applied step by step.',
+ ru: 'Новые версии включают улучшения, но содержат ломающие изменения; их нужно применять постепенно.'
+ },
+ modal: {
+ en: "A modal dialog opens as a window on top of the page, dims the underlying layer, and blocks all interaction with it. The modal dialog focuses the user's attention on its content.",
+ ru: 'Модальный диалог открывается окном поверх страницы, затемняет нижний слой и блокирует всякое взаимодействие с ним. Модальный диалог фокусирует внимание пользователя на своем содержимом.'
+ },
+ navbar: {
+ en: 'The main menu allows for product navigation. It consists of a logo and section links. You can also add an application switch, main action button, notification center, and search.',
+ ru: 'Главное меню организует навигацию в продукте. Оно состоит из логотипа, ссылок на разделы, дополнительно можно разместить переключатель приложений, кнопку главного действия, центр уведомлений и поиск.'
+ },
+ 'notification-center': {
+ en: 'Notification center — a panel for application notifications.',
+ ru: 'Notification center — панель уведомлений о работе приложений'
+ },
+ 'number-formatter': {
+ en: 'Every language has its own norms for writing numbers: the decimal separator, digit grouping, the labels for thousands and millions. Formatters apply the norms of the current locale and update the…',
+ ru: 'У каждого языка свои нормы записи чисел: разделитель дробной части, группировка разрядов, обозначение тысяч и миллионов. Форматтеры применяют нормы текущей локали и обновляют запись при переключении…'
+ },
+ 'overflow-items': {
+ en: 'Component for automatically hiding elements with dynamic adaptation to the container width.',
+ ru: 'Компонент для автоматического скрытия элементов с динамической адаптацией под ширину контейнера.'
+ },
+ popover: {
+ en: 'A popover is a small non-modal dialog without dimming that opens next to a trigger element. It can contain text, input fields, and any other controls.',
+ ru: 'Поповер — небольшой немодальный диалог без затемнения, который открывается рядом с триггерным элементом. Он может содержать текст, поля ввода, любые другие элементы управления.'
+ },
+ 'progress-bar': {
+ en: 'kbq-progress-bar is a component that allows display progress bar.',
+ ru: 'kbq-progress-bar - компонент, отображающий индикатор выполнения.'
+ },
+ 'progress-spinner': {
+ en: 'kbq-progress-spinner is a component that allows display progress spinner.',
+ ru: 'kbq-progress-spinner - компонент, отображающий индикатор загрузки в виде спиннера.'
+ },
+ radio: {
+ en: 'Radio buttons allow users to select from a set of mutually exclusive, related options.',
+ ru: 'С помощью радиокнопок пользователи делают выбор из набора взаимоисключающих, связанных между собой вариантов.'
+ },
+ resizer: {
+ en: 'Directive for resizing an element in a specified direction using the mouse pointer.',
+ ru: 'Директива для изменения размера элемента в заданном направлении при помощи указателя мыши.'
+ },
+ schematics: {
+ en: 'As part of the Koobiq design system, a set of schematics is provided — CLI tools designed to automate library installation, perform migrations, and update components in Angular projects.',
+ ru: 'В рамках дизайн-системы Koobiq предоставляется набор схематиков — CLI-инструментов, предназначенных для автоматизации установки библиотеки, выполнения миграций и обновления компонентов в…'
+ },
+ scrollbar: {
+ en: "KbqScrollbar adds a customizable scrollbar to a scrollable content area. Scrolling uses the browser's native mechanism, preserving mouse wheel, touch gesture, and keyboard controls.",
+ ru: 'KbqScrollbar добавляет настраиваемый скроллбар к области с прокручиваемым содержимым. Прокрутка выполняется нативным механизмом браузера, поэтому сохраняется управление колёсиком мыши, жестами и…'
+ },
+ 'search-expandable': {
+ en: 'A search field that expands from a compact icon button.',
+ ru: 'Поле поиска, которое разворачивается из компактной кнопки-иконки.'
+ },
+ select: {
+ en: 'Select - allows the user to select one or more values from a predefined list.',
+ ru: 'Селект позволяет выбрать одно или несколько значений из предопределенного списка.'
+ },
+ sidebar: {
+ en: 'Component designed to add collapsible side content.',
+ ru: 'Компонент, предназначенный для добавления сворачиваемого бокового контента.'
+ },
+ sidepanel: {
+ en: 'A sidepanel is a window that slides in from the edge of the screen and appears on top of the main page content.',
+ ru: 'Сайдпанель — это окно, которое выезжает из-за границы экрана и располагается поверх основного контента страницы.'
+ },
+ skeleton: {
+ en: 'KbqSkeleton is a temporary placeholder that occupies the space of the loading element and displays its approximate layout while the actual content is still loading.',
+ ru: 'KbqSkeleton - это временная заглушка, которая занимает место подгружаемого элемента и отображает его примерный макет, пока реальный контент еще загружается.'
+ },
+ 'split-button': {
+ en: 'A split button combines several related actions into a single control. The primary action is immediately accessible, while additional options are hidden in a dropdown menu.',
+ ru: 'Сплит-кнопка объединяет несколько связанных действий в одном элементе управления. Основное действие доступно сразу, а дополнительные варианты скрыты в выпадающем меню.'
+ },
+ splitter: {
+ en: 'Splitter divides an area into resizable horizontal or vertical panels and supports nested layouts.',
+ ru: 'Splitter разделяет область на изменяемые по размеру горизонтальные или вертикальные панели и поддерживает вложенные компоновки.'
+ },
+ table: {
+ en: 'A table is similar to a grid but simpler in structure. Users cannot sort columns or change their widths. Only standard HTML table capabilities are available.',
+ ru: 'Таблица похожа на грид, но устроена проще. Пользователь не может сортировать, менять ширину колонок. Доступны только возможности HTML-таблицы.'
+ },
+ tabs: {
+ en: 'Tabs divide content into groups and allow you to switch between them without reloading the page.',
+ ru: 'Вкладки разделяют содержимое на группы и позволяют переключаться между ними без перезагрузки страницы.'
+ },
+ tag: {
+ en: 'Tags are used within the Tag list component in input fields to represent selected values: Select Multiple, Tag autocomplete, Tag input.',
+ ru: 'Теги используются в составе Tag list в полях ввода для обозначения выбранных значений: Select Multiple, Tag autocomplete, Tag input'
+ },
+ 'tag-autocomplete': {
+ en: 'Tags Autocomplete is used to select one or multiple values from a list and to input custom values.',
+ ru: 'Tags Autocomplete используется для выбора одного или нескольких значений из списка и для ввода собственных значений.'
+ },
+ 'tag-input': {
+ en: 'Tag removal order:',
+ ru: 'Порядок удаления тегов:'
+ },
+ 'tag-list': {
+ en: 'Tag removal order:',
+ ru: 'Порядок удаления тегов:'
+ },
+ textarea: {
+ en: 'KbqTextarea — component for multi-line text input.',
+ ru: 'KbqTextarea — компонент для многострочного ввода текста.'
+ },
+ theming: {
+ en: 'A theme in Koobiq is a set of CSS variables. Switching a theme swaps the values; nothing else has to change. This guide shows how to plug a theme in, how to switch it, and how to use its values in…',
+ ru: 'Тема в Koobiq — это набор CSS-переменных. Смена темы меняет значения переменных, всё остальное остаётся как есть. Здесь описано, как подключить тему, как её переключать и как брать её значения в…'
+ },
+ 'time-range': {
+ en: 'Time range selection menu. The user can choose one of the preset values or specify the time range manually.',
+ ru: 'Меню выбора периода. Пользователь может выбрать одно из предустановленных значений или самостоятельно указать временной диапазон.'
+ },
+ timepicker: {
+ en: 'A time field (timepicker) is a special input field that allows entering only time.',
+ ru: 'Поле с временем (таймпикер) — это специальное поле ввода, которое позволяет вводить только время.'
+ },
+ timezone: {
+ en: 'kbq-timezone-select is a timezone selection component that extends kbq-select. It has the same capabilities, except for multiple selection. It can work with both kbq-timezone-option and kbq-option.',
+ ru: 'kbq-timezone-select - компонент выбора таймзоны является расширением kbq-select. Имеет те же возможности, за исключением множественного выбора. Может работать как с kbq-timezone-option, так и с…'
+ },
+ title: {
+ en: "The kbq-title directive shows a tooltip with the full text when the element's content does not fit and is truncated with an ellipsis. The tooltip appears only on actual overflow — on hover or…",
+ ru: 'Директива kbq-title показывает тултип с полным текстом, когда содержимое элемента не помещается и обрезается многоточием. Подсказка появляется только при реальном переполнении — по наведению курсора…'
+ },
+ toast: {
+ en: 'A toast is a non-modal message displayed on top of all elements on the screen. It differs from a popup in that it appears in the top right corner of the screen and is not tied to any specific…',
+ ru: 'Тост — это немодальное сообщение поверх всех элементов на экране. Отличается от всплывающего окна тем, что выводится в правом верхнем углу экрана и не привязан ни к какому конкретному элементу…'
+ },
+ toggle: {
+ en: 'State toggle button: on or off. For example, Wi-Fi is turned on or off on a phone.',
+ ru: 'Кнопка-переключатель состояний: включено или выключено. Например, включен или выключен Wi-Fi в телефоне.'
+ },
+ tooltip: {
+ en: 'Tooltip — a hint that appears on hover or focus. The tooltip closes when the cursor is moved away, focus is removed, or the page is scrolled.',
+ ru: 'Тултип — подсказка, которая появляется по наведению или фокусу. Тултип закрывается, если отвести указатель, убрать фокус или проскроллить страницу.'
+ },
+ 'top-bar': {
+ en: 'Topbar is a toolbar that always remains visible on the page and provides quick access to navigation and controls.',
+ ru: 'Topbar — это панель инструментов, которая всегда остается видимой на странице и обеспечивает быстрый доступ к навигации и управлению.'
+ },
+ tree: {
+ en: 'A hierarchical list (tree) is a tree-structured catalog for working with large amounts of data.',
+ ru: 'Иерархический список (дерево) — древовидный каталог с большим количеством данных.'
+ },
+ 'tree-select': {
+ en: 'In multiple selection mode all the nodes can be selected at once. The feature is off by default — turn it on with the selectAll attribute, and a master checkbox appears above the tree.',
+ ru: 'В режиме мультивыбора есть возможность выбрать все узлы сразу. Эта функция отключена по умолчанию — включите её атрибутом selectAll, и над деревом появится мастер-чекбокс.'
+ },
+ typography: {
+ en: 'Koobiq uses the Inter and JetBrains Mono font by default.',
+ ru: 'Koobiq по умолчанию использует шрифты Inter и JetBrains Mono.'
+ },
+ username: {
+ en: 'The pattern is used when the interface refers to an internal user.',
+ ru: 'Компонент отображает информацию о пользователе в едином стиле. Надпись формируется на основе данных профиля с учётом выбранного режима отображения и настройки компактности.'
+ },
+ validation: {
+ en: "Form-field controls display errors via ErrorStateMatcher — a small policy object that decides when to surface a control's existing errors.",
+ ru: 'Поля формы показывают ошибки через ErrorStateMatcher — небольшой объект-политику, который решает, когда выводить уже существующие ошибки контрола пользователю.'
+ },
+ versioning: {
+ en: 'For the sake of keeping things simple, refer to the Semver spec for anything this document does not cover.',
+ ru: 'Для наших релизов мы придерживаемся версионирования основанного на Semver spec.'
+ }
+} as const;
diff --git a/apps/docs/src/app/services/__snapshots__/i18n.spec.ts.snap b/apps/docs/src/app/services/__snapshots__/i18n.spec.ts.snap
index 6bfd87a189..ee08da35ce 100644
--- a/apps/docs/src/app/services/__snapshots__/i18n.spec.ts.snap
+++ b/apps/docs/src/app/services/__snapshots__/i18n.spec.ts.snap
@@ -2,6 +2,10 @@
exports[`DOCS_TRANSLATIONS matches the exact strings previously inlined across the docs components 1`] = `
{
+ "apiTab": {
+ "en": "API",
+ "ru": "API",
+ },
"copied": {
"en": "Copied",
"ru": "Скопировано",
diff --git a/apps/docs/src/app/services/i18n.ts b/apps/docs/src/app/services/i18n.ts
index 153b4a5560..10d135b083 100644
--- a/apps/docs/src/app/services/i18n.ts
+++ b/apps/docs/src/app/services/i18n.ts
@@ -36,6 +36,7 @@ export const DOCS_TRANSLATIONS = {
// component viewer wrapper + tabs
improvementSuggestions: { ru: 'Предложения по улучшению', en: 'Suggestions for improvement' },
overviewTab: { ru: 'Обзор', en: 'Overview' },
+ apiTab: { ru: 'API', en: 'API' },
examplesTab: { ru: 'Примеры', en: 'Examples' },
viewSourceOnGitHub: { ru: 'Исходный код', en: 'Source code' },
diff --git a/apps/docs/src/app/services/seo.spec.ts b/apps/docs/src/app/services/seo.spec.ts
new file mode 100644
index 0000000000..5afd1499d3
--- /dev/null
+++ b/apps/docs/src/app/services/seo.spec.ts
@@ -0,0 +1,93 @@
+import { TestBed } from '@angular/core/testing';
+import { DocsLocale } from '../constants/locale';
+import { docsResolveSeo, DocsSeoService } from './seo';
+
+describe(docsResolveSeo.name, () => {
+ it('uses the localized Markdown summary and component preview for an overview page', () => {
+ const seo = docsResolveSeo('/en/components/alert/overview', DocsLocale.En);
+
+ expect(seo.title).toBe('Alert — Overview · Koobiq');
+ expect(seo.description).toBe(
+ 'Shows important information on a page. Can contain a hint, signal a status change, or indicate a problem.'
+ );
+ expect(seo.canonicalUrl).toBe('https://koobiq.io/en/components/alert/overview');
+ expect(seo.image).toEqual({
+ url: 'https://koobiq.io/assets/images/welcome/alerts-light.png',
+ alt: 'Alert — Koobiq component',
+ width: 400,
+ height: 280
+ });
+ });
+
+ it('generates distinct localized metadata for examples', () => {
+ const seo = docsResolveSeo('/ru/components/select/examples?query=ignored', DocsLocale.Ru);
+
+ expect(seo.title).toBe('Select — Примеры · Koobiq');
+ expect(seo.description).toContain('Примеры использования Select');
+ expect(seo.canonicalUrl).toBe('https://koobiq.io/ru/components/select/examples');
+ expect(seo.alternates).toEqual([
+ { locale: 'en', url: 'https://koobiq.io/en/components/select/examples' },
+ { locale: 'ru', url: 'https://koobiq.io/ru/components/select/examples' }
+ ]);
+ });
+
+ it('uses the main illustration when an item has no preview', () => {
+ const seo = docsResolveSeo('/en/components/button-group/api', DocsLocale.En);
+
+ expect(seo.image.url).toBe('https://koobiq.io/assets/images/koobiq-illustration-wip.png');
+ expect(seo.image.width).toBe(2048);
+ expect(seo.image.height).toBe(1024);
+ });
+
+ it('uses the shared UI translation for a design-token tab title', () => {
+ const seo = docsResolveSeo('/ru/main/design-tokens/palette', DocsLocale.Ru);
+
+ expect(seo.title).toBe('Дизайн-токены — Инженерная палитра · Koobiq');
+ });
+
+ it('uses a localized Markdown summary added before the examples', () => {
+ const seo = docsResolveSeo('/en/components/list/overview', DocsLocale.En);
+
+ expect(seo.description).toBe(
+ 'List supports groups, single or multiple selection, keyboard navigation, and virtual scrolling.'
+ );
+ });
+
+ it('marks non-localized utility and error routes as noindex', () => {
+ const seo = docsResolveSeo('/404', DocsLocale.En);
+
+ expect(seo.noIndex).toBe(true);
+ expect(seo.canonicalUrl).toBeNull();
+ expect(seo.alternates).toEqual([]);
+ });
+});
+
+describe(DocsSeoService.name, () => {
+ let service: DocsSeoService;
+
+ beforeEach(() => {
+ document.head
+ .querySelectorAll('meta[name], meta[property], link[rel="canonical"], link[rel="alternate"]')
+ .forEach((element) => element.remove());
+
+ TestBed.configureTestingModule({ providers: [DocsSeoService] });
+ service = TestBed.inject(DocsSeoService);
+ });
+
+ it('applies SSG-safe metadata and replaces it on navigation', () => {
+ service.update('/en/components/alert/overview', DocsLocale.En);
+
+ expect(document.documentElement.lang).toBe('en');
+ expect(document.title).toBe('Alert — Overview · Koobiq');
+ expect(document.querySelector('meta[property="og:image"]')?.getAttribute('content')).toContain(
+ 'alerts-light.png'
+ );
+ expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(2);
+
+ service.update('/404', DocsLocale.En);
+
+ expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe('noindex,follow');
+ expect(document.querySelector('link[rel="canonical"]')).toBeNull();
+ expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(0);
+ });
+});
diff --git a/apps/docs/src/app/services/seo.ts b/apps/docs/src/app/services/seo.ts
new file mode 100644
index 0000000000..3a532e6002
--- /dev/null
+++ b/apps/docs/src/app/services/seo.ts
@@ -0,0 +1,300 @@
+import { DOCUMENT } from '@angular/common';
+import { inject, Injectable } from '@angular/core';
+import { Meta, Title } from '@angular/platform-browser';
+import { DOCS_SUPPORTED_LOCALES, DocsLocale } from '../constants/locale';
+import { DOCS_SEO_DESCRIPTIONS } from '../seo-descriptions';
+import {
+ docsGetCategoryById,
+ docsGetItemById,
+ DocsSeoMeta,
+ DocsStructureCategoryId,
+ DocsStructureItem,
+ DocsStructureItemId,
+ DocsStructureItemTab,
+ DocsStructureTokensTab
+} from '../structure';
+import { DOCS_TRANSLATIONS } from './i18n';
+
+const SITE_NAME = 'Koobiq';
+const SITE_ORIGIN = 'https://koobiq.io';
+const FALLBACK_IMAGE_PATH = '/assets/images/koobiq-illustration-wip.png';
+const ICONS_IMAGE_PATH = '/assets/images/welcome/icons-light.png';
+const TITLE_SEPARATOR = '·';
+
+const SITE_DESCRIPTION: Record = {
+ [DocsLocale.Ru]: 'Koobiq — библиотека компонентов и дизайн-система для Angular.',
+ [DocsLocale.En]: 'Koobiq — Angular components library and design system.'
+};
+
+const HOME_TITLE: Record = {
+ [DocsLocale.Ru]: 'Koobiq — дизайн-система для Angular',
+ [DocsLocale.En]: 'Koobiq — Angular design system'
+};
+
+const ICONS_DESCRIPTION: Record = {
+ [DocsLocale.Ru]: 'Каталог иконок дизайн-системы Koobiq с поиском и вариантами использования.',
+ [DocsLocale.En]: 'Koobiq design system icon catalog with search and usage options.'
+};
+
+const TAB_TITLE: Record> = {
+ [DocsStructureItemTab.Overview]: DOCS_TRANSLATIONS.overviewTab,
+ [DocsStructureItemTab.Api]: DOCS_TRANSLATIONS.apiTab,
+ [DocsStructureItemTab.Examples]: DOCS_TRANSLATIONS.examplesTab,
+ [DocsStructureTokensTab.Colors]: DOCS_TRANSLATIONS.tokensTabColors,
+ [DocsStructureTokensTab.Typography]: DOCS_TRANSLATIONS.tokensTabTypography,
+ [DocsStructureTokensTab.Shadows]: DOCS_TRANSLATIONS.tokensTabShadows,
+ [DocsStructureTokensTab.BorderRadius]: DOCS_TRANSLATIONS.tokensTabBorderRadius,
+ [DocsStructureTokensTab.Sizes]: DOCS_TRANSLATIONS.tokensTabSizes,
+ [DocsStructureTokensTab.Palette]: DOCS_TRANSLATIONS.tokensTabPalette,
+ [DocsStructureTokensTab.Semantic]: DOCS_TRANSLATIONS.tokensTabSemantic
+};
+
+const OG_LOCALE: Record = {
+ [DocsLocale.Ru]: 'ru_RU',
+ [DocsLocale.En]: 'en_US'
+};
+
+type DocsSeoImage = {
+ url: string;
+ alt: string;
+ width: number;
+ height: number;
+};
+
+export type DocsResolvedSeo = {
+ title: string;
+ description: string;
+ canonicalUrl: string | null;
+ alternates: ReadonlyArray<{ locale: DocsLocale; url: string }>;
+ image: DocsSeoImage;
+ keywords: readonly string[];
+ locale: DocsLocale;
+ noIndex: boolean;
+};
+
+type SeoDescriptions = Readonly>>>;
+
+const generatedDescriptions = DOCS_SEO_DESCRIPTIONS as SeoDescriptions;
+
+const localizedValue = (value: Partial> | undefined, locale: DocsLocale): T | undefined => {
+ return value?.[locale];
+};
+
+const resolveItemImagePath = (item: DocsStructureItem): string => {
+ if (!item.svgPreview) return FALLBACK_IMAGE_PATH;
+
+ return `/assets/images/welcome/${encodeURIComponent(item.svgPreview)}-light.png`;
+};
+
+const resolveImage = (path: string, alt: string): DocsSeoImage => {
+ const isFallback = path === FALLBACK_IMAGE_PATH;
+
+ return {
+ url: `${SITE_ORIGIN}${path}`,
+ alt,
+ width: isFallback ? 2048 : 400,
+ height: isFallback ? 1024 : 280
+ };
+};
+
+const defaultImageAlt = (name: string, locale: DocsLocale): string => {
+ return locale === DocsLocale.Ru ? `${name} — компонент Koobiq` : `${name} — Koobiq component`;
+};
+
+const resolveTabDescription = (item: DocsStructureItem, tab: string | undefined, locale: DocsLocale): string => {
+ const itemName = item.name[locale];
+
+ if (tab === DocsStructureItemTab.Api) {
+ return locale === DocsLocale.Ru
+ ? `API ${itemName} в Koobiq: свойства, события, методы и связанные типы.`
+ : `Koobiq ${itemName} API: properties, events, methods, and related types.`;
+ }
+
+ if (tab === DocsStructureItemTab.Examples) {
+ return locale === DocsLocale.Ru
+ ? `Примеры использования ${itemName} в Angular-приложениях с дизайн-системой Koobiq.`
+ : `Examples of using ${itemName} in Angular applications with the Koobiq design system.`;
+ }
+
+ if (item.id === DocsStructureItemId.DesignTokens && tab && TAB_TITLE[tab as DocsStructureTokensTab]) {
+ const tabTitle = TAB_TITLE[tab as DocsStructureTokensTab][locale];
+
+ return locale === DocsLocale.Ru
+ ? `${tabTitle}: дизайн-токены Koobiq для создания согласованных интерфейсов.`
+ : `${tabTitle}: Koobiq design tokens for building consistent interfaces.`;
+ }
+
+ return (
+ generatedDescriptions[item.id]?.[locale] ??
+ (locale === DocsLocale.Ru
+ ? `Документация по ${itemName} в дизайн-системе Koobiq для Angular.`
+ : `${itemName} documentation for the Koobiq Angular design system.`)
+ );
+};
+
+const resolveItemSeo = (
+ item: DocsStructureItem,
+ tab: string | undefined,
+ locale: DocsLocale
+): Pick => {
+ const tabMeta = tab ? item.seo?.tabs?.[tab as DocsStructureItemTab | DocsStructureTokensTab] : undefined;
+ const meta: DocsSeoMeta | undefined = tabMeta;
+ const itemTitle =
+ localizedValue(meta?.title, locale) ?? localizedValue(item.seo?.title, locale) ?? item.name[locale];
+ const tabTitle = tab ? TAB_TITLE[tab as DocsStructureItemTab | DocsStructureTokensTab]?.[locale] : undefined;
+ const title = `${itemTitle}${tabTitle ? ` — ${tabTitle}` : ''} ${TITLE_SEPARATOR} ${SITE_NAME}`;
+ const description =
+ localizedValue(meta?.description, locale) ??
+ localizedValue(item.seo?.description, locale) ??
+ resolveTabDescription(item, tab, locale);
+ const imagePath = meta?.image ?? item.seo?.image ?? resolveItemImagePath(item);
+ const imageAlt =
+ localizedValue(meta?.imageAlt, locale) ??
+ localizedValue(item.seo?.imageAlt, locale) ??
+ defaultImageAlt(item.name[locale], locale);
+
+ return {
+ title,
+ description,
+ image: resolveImage(imagePath, imageAlt),
+ keywords: localizedValue(meta?.keywords, locale) ?? localizedValue(item.seo?.keywords, locale) ?? [],
+ noIndex: meta?.noIndex ?? item.seo?.noIndex ?? false
+ };
+};
+
+/** Resolves all route-dependent SEO data without touching the DOM, so it can be tested exhaustively. */
+export const docsResolveSeo = (rawPath: string, locale: DocsLocale): DocsResolvedSeo => {
+ const path = rawPath.split(/[?#]/)[0];
+ const segments = path.split('/').filter(Boolean);
+ const hasSupportedLocale = DOCS_SUPPORTED_LOCALES.includes(segments[0]);
+ const canonicalUrl = hasSupportedLocale ? `${SITE_ORIGIN}${path}` : null;
+ const alternates = hasSupportedLocale
+ ? DOCS_SUPPORTED_LOCALES.map((alternateLocale) => ({
+ locale: alternateLocale as DocsLocale,
+ url: `${SITE_ORIGIN}/${alternateLocale}${segments.length > 1 ? `/${segments.slice(1).join('/')}` : ''}`
+ }))
+ : [];
+ const categoryId = segments[1] as DocsStructureCategoryId | undefined;
+ const itemId = segments[2] as DocsStructureItemId | undefined;
+ const tab = segments[3];
+ const item = categoryId && itemId ? docsGetItemById(itemId, categoryId) : undefined;
+
+ if (item) {
+ return { ...resolveItemSeo(item, tab, locale), canonicalUrl, alternates, locale };
+ }
+
+ if (categoryId === DocsStructureCategoryId.Icons) {
+ const categoryName = docsGetCategoryById(DocsStructureCategoryId.Icons)!.name[locale];
+
+ return {
+ title: `${categoryName} ${TITLE_SEPARATOR} ${SITE_NAME}`,
+ description: ICONS_DESCRIPTION[locale],
+ canonicalUrl,
+ alternates,
+ image: resolveImage(ICONS_IMAGE_PATH, defaultImageAlt(categoryName, locale)),
+ keywords: [],
+ locale,
+ noIndex: false
+ };
+ }
+
+ const isHome = hasSupportedLocale && segments.length === 1;
+
+ return {
+ title: isHome ? HOME_TITLE[locale] : SITE_NAME,
+ description: SITE_DESCRIPTION[locale],
+ canonicalUrl,
+ alternates,
+ image: resolveImage(FALLBACK_IMAGE_PATH, SITE_NAME),
+ keywords: [],
+ locale,
+ noIndex: !isHome
+ };
+};
+
+@Injectable({ providedIn: 'root' })
+export class DocsSeoService {
+ private readonly title = inject(Title);
+ private readonly meta = inject(Meta);
+ private readonly document = inject(DOCUMENT);
+
+ update(path: string, locale: DocsLocale): void {
+ const seo = docsResolveSeo(path, locale);
+
+ this.document.documentElement.lang = locale;
+ this.title.setTitle(seo.title);
+ this.updateMeta(seo);
+ this.updateCanonical(seo.canonicalUrl);
+ this.updateAlternates(seo.alternates);
+ }
+
+ private updateMeta(seo: DocsResolvedSeo): void {
+ this.setMeta('name', 'description', seo.description);
+ this.setMeta('name', 'keywords', seo.keywords.length ? seo.keywords.join(', ') : null);
+ this.setMeta('name', 'robots', seo.noIndex ? 'noindex,follow' : null);
+
+ this.setMeta('property', 'og:title', seo.title);
+ this.setMeta('property', 'og:description', seo.description);
+ this.setMeta('property', 'og:type', 'website');
+ this.setMeta('property', 'og:site_name', SITE_NAME);
+ this.setMeta('property', 'og:url', seo.canonicalUrl);
+ this.setMeta('property', 'og:locale', OG_LOCALE[seo.locale]);
+ this.setMeta(
+ 'property',
+ 'og:locale:alternate',
+ OG_LOCALE[seo.locale === DocsLocale.Ru ? DocsLocale.En : DocsLocale.Ru]
+ );
+ this.setMeta('property', 'og:image', seo.image.url);
+ this.setMeta('property', 'og:image:type', 'image/png');
+ this.setMeta('property', 'og:image:width', String(seo.image.width));
+ this.setMeta('property', 'og:image:height', String(seo.image.height));
+ this.setMeta('property', 'og:image:alt', seo.image.alt);
+
+ this.setMeta('name', 'twitter:card', 'summary_large_image');
+ this.setMeta('name', 'twitter:title', seo.title);
+ this.setMeta('name', 'twitter:description', seo.description);
+ this.setMeta('name', 'twitter:image', seo.image.url);
+ this.setMeta('name', 'twitter:image:alt', seo.image.alt);
+ }
+
+ private setMeta(attribute: 'name' | 'property', key: string, content: string | null): void {
+ const selector = `${attribute}="${key}"`;
+
+ if (content) {
+ this.meta.updateTag({ [attribute]: key, content }, selector);
+ } else {
+ this.meta.removeTag(selector);
+ }
+ }
+
+ private updateCanonical(href: string | null): void {
+ let link = this.document.querySelector('link[rel="canonical"]');
+
+ if (!href) {
+ link?.remove();
+
+ return;
+ }
+
+ if (!link) {
+ link = this.document.createElement('link');
+ link.rel = 'canonical';
+ this.document.head.appendChild(link);
+ }
+
+ link.href = href;
+ }
+
+ private updateAlternates(alternates: DocsResolvedSeo['alternates']): void {
+ this.document.querySelectorAll('link[rel="alternate"][hreflang]').forEach((link) => link.remove());
+
+ for (const alternate of alternates) {
+ const link = this.document.createElement('link');
+
+ link.rel = 'alternate';
+ link.hreflang = alternate.locale;
+ link.href = alternate.url;
+ this.document.head.appendChild(link);
+ }
+ }
+}
diff --git a/apps/docs/src/app/services/title-strategy.ts b/apps/docs/src/app/services/title-strategy.ts
index 064d6bd8e6..73a1033d87 100644
--- a/apps/docs/src/app/services/title-strategy.ts
+++ b/apps/docs/src/app/services/title-strategy.ts
@@ -1,101 +1,19 @@
-import { DOCUMENT } from '@angular/common';
import { inject, Injectable, Injector } from '@angular/core';
-import { Meta, Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
-import { DocsLocale } from '../constants/locale';
-import { docsGetCategoryById, docsGetItemById, DocsStructureCategoryId, DocsStructureItemId } from '../structure';
import { DocsLocaleService } from './locale';
+import { DocsSeoService } from './seo';
-const SITE_NAME = 'Koobiq';
-const CANONICAL_ORIGIN = 'https://koobiq.io';
-const TITLE_SEPARATOR = '·';
-
-const DESCRIPTION: Record = {
- [DocsLocale.Ru]: 'Koobiq — библиотека компонентов и дизайн-система для Angular.',
- [DocsLocale.En]: 'Koobiq — Angular components library and design system.'
-};
-
-/**
- * Central title/meta strategy for the docs site. On every successful navigation it derives a
- * unique, localized `` from the `structure` registry and keeps the SEO/social meta tags
- * (`description`, Open Graph, Twitter card, canonical) in sync. This replaces the ad-hoc titles
- * that were previously set inside individual viewers.
- */
+/** Connects successful Router navigations to the centralized SEO service. */
@Injectable()
export class DocsTitleStrategy extends TitleStrategy {
- private readonly title = inject(Title);
- private readonly meta = inject(Meta);
- private readonly document = inject(DOCUMENT);
- // Resolve DocsLocaleService lazily: it injects Router, and the Router eagerly resolves
- // TitleStrategy, so injecting it here directly would form a Router → TitleStrategy →
- // DocsLocaleService → Router cycle (NG0200). By the time updateTitle runs, Router is constructed.
+ private readonly seo = inject(DocsSeoService);
+ // DocsLocaleService injects Router. Resolve it after Router construction to avoid a
+ // Router → TitleStrategy → DocsLocaleService → Router dependency cycle.
private readonly injector = inject(Injector);
override updateTitle(snapshot: RouterStateSnapshot): void {
const localeService = this.injector.get(DocsLocaleService);
- const locale = localeService.locale;
- const path = snapshot.url.split(/[?#]/)[0];
- const pageTitle = this.resolvePageTitle(path, locale, localeService);
- const fullTitle = pageTitle ? `${pageTitle} ${TITLE_SEPARATOR} ${SITE_NAME}` : SITE_NAME;
-
- this.title.setTitle(fullTitle);
- this.updateMeta(fullTitle, DESCRIPTION[locale], `${CANONICAL_ORIGIN}${path}`);
- }
-
- /** Resolves the human-readable page name from the URL using the structure registry. */
- private resolvePageTitle(path: string, locale: DocsLocale, localeService: DocsLocaleService): string | null {
- const segments = path.split('/').filter(Boolean);
-
- // segments[0] is the locale (or an out-of-locale route such as `404`).
- if (!segments.length || !localeService.isSupportedLocale(segments[0])) {
- return null;
- }
-
- const categoryId = segments[1] as DocsStructureCategoryId | undefined;
-
- if (!categoryId) {
- // Locale root (welcome page) — the bare site name reads best.
- return null;
- }
-
- const itemId = segments[2] as DocsStructureItemId | undefined;
-
- if (itemId) {
- const item = docsGetItemById(itemId, categoryId);
-
- if (item) {
- return item.name[locale];
- }
- }
-
- return docsGetCategoryById(categoryId)?.name[locale] ?? null;
- }
-
- private updateMeta(title: string, description: string, canonicalUrl: string): void {
- this.meta.updateTag({ name: 'description', content: description });
-
- this.meta.updateTag({ property: 'og:title', content: title });
- this.meta.updateTag({ property: 'og:description', content: description });
- this.meta.updateTag({ property: 'og:type', content: 'website' });
- this.meta.updateTag({ property: 'og:site_name', content: SITE_NAME });
- this.meta.updateTag({ property: 'og:url', content: canonicalUrl });
-
- this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
- this.meta.updateTag({ name: 'twitter:title', content: title });
- this.meta.updateTag({ name: 'twitter:description', content: description });
-
- this.setCanonical(canonicalUrl);
- }
-
- private setCanonical(href: string): void {
- let link = this.document.querySelector('link[rel="canonical"]');
-
- if (!link) {
- link = this.document.createElement('link');
- link.setAttribute('rel', 'canonical');
- this.document.head.appendChild(link);
- }
- link.setAttribute('href', href);
+ this.seo.update(snapshot.url, localeService.locale);
}
}
diff --git a/apps/docs/src/app/structure.ts b/apps/docs/src/app/structure.ts
index d4616e4b08..4de664160b 100644
--- a/apps/docs/src/app/structure.ts
+++ b/apps/docs/src/app/structure.ts
@@ -92,6 +92,21 @@ export enum DocsStructureItemId {
Validation = 'validation'
}
+export type DocsLocalizedSeoValue = Partial>;
+
+export type DocsSeoMeta = {
+ title?: DocsLocalizedSeoValue;
+ description?: DocsLocalizedSeoValue;
+ image?: string;
+ imageAlt?: DocsLocalizedSeoValue;
+ keywords?: DocsLocalizedSeoValue;
+ noIndex?: boolean;
+};
+
+export type DocsItemSeoMeta = DocsSeoMeta & {
+ tabs?: Partial>;
+};
+
export type DocsStructureItem = {
id: DocsStructureItemId;
name: Record;
@@ -100,6 +115,8 @@ export type DocsStructureItem = {
isGuide?: boolean;
apiId?: string;
svgPreview?: string;
+ /** Optional per-page SEO overrides. Missing values are derived from the item and its Markdown. */
+ seo?: DocsItemSeoMeta;
/**
* Path to the directory containing the item's source in this repository,
* relative to the repo root (e.g. `packages/components/accordion`).
diff --git a/apps/docs/src/prerender-routes.txt b/apps/docs/src/prerender-routes.txt
index bdb08d9a36..ea0e3cf58e 100644
--- a/apps/docs/src/prerender-routes.txt
+++ b/apps/docs/src/prerender-routes.txt
@@ -37,6 +37,7 @@
/en/components/button-toggle/api
/en/components/checkbox/overview
/en/components/checkbox/api
+/en/components/checkbox/examples
/en/components/clamped-list/overview
/en/components/clamped-list/api
/en/components/clamped-text/overview
@@ -230,6 +231,7 @@
/ru/components/button-toggle/api
/ru/components/checkbox/overview
/ru/components/checkbox/api
+/ru/components/checkbox/examples
/ru/components/clamped-list/overview
/ru/components/clamped-list/api
/ru/components/clamped-text/overview
diff --git a/apps/docs/src/sitemap.xml b/apps/docs/src/sitemap.xml
index 2b33f94192..c7a6ad9c8f 100644
--- a/apps/docs/src/sitemap.xml
+++ b/apps/docs/src/sitemap.xml
@@ -1,5 +1,11 @@
+
+ https://koobiq.io/en
+
+
+ https://koobiq.io/ru
+
https://koobiq.io/en/main/installation/overview
@@ -102,6 +108,12 @@
https://koobiq.io/ru/components/actions-panel/api
+
+ https://koobiq.io/en/components/actions-panel/examples
+
+
+ https://koobiq.io/ru/components/actions-panel/examples
+
https://koobiq.io/en/components/ag-grid/overview
@@ -168,6 +180,12 @@
https://koobiq.io/ru/components/breadcrumbs/api
+
+ https://koobiq.io/en/components/breadcrumbs/examples
+
+
+ https://koobiq.io/ru/components/breadcrumbs/examples
+
https://koobiq.io/en/components/button/overview
@@ -216,6 +234,12 @@
https://koobiq.io/ru/components/checkbox/api
+
+ https://koobiq.io/en/components/checkbox/examples
+
+
+ https://koobiq.io/ru/components/checkbox/examples
+
https://koobiq.io/en/components/clamped-list/overview
@@ -252,6 +276,12 @@
https://koobiq.io/ru/components/code-block/api
+
+ https://koobiq.io/en/components/code-block/examples
+
+
+ https://koobiq.io/ru/components/code-block/examples
+
https://koobiq.io/en/components/content-panel/overview
@@ -336,6 +366,12 @@
https://koobiq.io/ru/components/dynamic-translation/api
+
+ https://koobiq.io/en/components/dynamic-translation/examples
+
+
+ https://koobiq.io/ru/components/dynamic-translation/examples
+
https://koobiq.io/en/components/empty-state/overview
@@ -360,6 +396,12 @@
https://koobiq.io/ru/components/file-upload/api
+
+ https://koobiq.io/en/components/file-upload/examples
+
+
+ https://koobiq.io/ru/components/file-upload/examples
+
https://koobiq.io/en/components/filter-bar/overview
@@ -372,6 +414,12 @@
https://koobiq.io/ru/components/filter-bar/api
+
+ https://koobiq.io/en/components/filter-bar/examples
+
+
+ https://koobiq.io/ru/components/filter-bar/examples
+
https://koobiq.io/en/components/flag/overview
@@ -450,6 +498,12 @@
https://koobiq.io/ru/components/inline-edit/api
+
+ https://koobiq.io/en/components/inline-edit/examples
+
+
+ https://koobiq.io/ru/components/inline-edit/examples
+
https://koobiq.io/en/components/input/overview
@@ -462,6 +516,12 @@
https://koobiq.io/ru/components/input/api
+
+ https://koobiq.io/en/components/input/examples
+
+
+ https://koobiq.io/ru/components/input/examples
+
https://koobiq.io/en/components/layout-flex/overview
@@ -480,6 +540,12 @@
https://koobiq.io/ru/components/link/api
+
+ https://koobiq.io/en/components/link/examples
+
+
+ https://koobiq.io/ru/components/link/examples
+
https://koobiq.io/en/components/list/overview
@@ -492,6 +558,12 @@
https://koobiq.io/ru/components/list/api
+
+ https://koobiq.io/en/components/list/examples
+
+
+ https://koobiq.io/ru/components/list/examples
+
https://koobiq.io/en/components/markdown/overview
@@ -504,6 +576,12 @@
https://koobiq.io/ru/components/markdown/api
+
+ https://koobiq.io/en/components/markdown/examples
+
+
+ https://koobiq.io/ru/components/markdown/examples
+
https://koobiq.io/en/components/modal/overview
@@ -516,6 +594,12 @@
https://koobiq.io/ru/components/modal/api
+
+ https://koobiq.io/en/components/modal/examples
+
+
+ https://koobiq.io/ru/components/modal/examples
+
https://koobiq.io/en/components/navbar/overview
@@ -528,6 +612,12 @@
https://koobiq.io/ru/components/navbar/api
+
+ https://koobiq.io/en/components/navbar/examples
+
+
+ https://koobiq.io/ru/components/navbar/examples
+
https://koobiq.io/en/components/notification-center/overview
@@ -540,6 +630,12 @@
https://koobiq.io/ru/components/notification-center/api
+
+ https://koobiq.io/en/components/notification-center/examples
+
+
+ https://koobiq.io/ru/components/notification-center/examples
+
https://koobiq.io/en/components/overflow-items/overview
@@ -552,6 +648,12 @@
https://koobiq.io/ru/components/overflow-items/api
+
+ https://koobiq.io/en/components/overflow-items/examples
+
+
+ https://koobiq.io/ru/components/overflow-items/examples
+
https://koobiq.io/en/components/loader-overlay/overview
@@ -576,6 +678,12 @@
https://koobiq.io/ru/components/popover/api
+
+ https://koobiq.io/en/components/popover/examples
+
+
+ https://koobiq.io/ru/components/popover/examples
+
https://koobiq.io/en/components/progress-bar/overview
@@ -636,6 +744,12 @@
https://koobiq.io/ru/components/scrollbar/api
+
+ https://koobiq.io/en/components/scrollbar/examples
+
+
+ https://koobiq.io/ru/components/scrollbar/examples
+
https://koobiq.io/en/components/search-expandable/overview
@@ -660,6 +774,12 @@
https://koobiq.io/ru/components/select/api
+
+ https://koobiq.io/en/components/select/examples
+
+
+ https://koobiq.io/ru/components/select/examples
+
https://koobiq.io/en/components/sidebar/overview
@@ -672,6 +792,12 @@
https://koobiq.io/ru/components/sidebar/api
+
+ https://koobiq.io/en/components/sidebar/examples
+
+
+ https://koobiq.io/ru/components/sidebar/examples
+
https://koobiq.io/en/components/sidepanel/overview
@@ -684,6 +810,12 @@
https://koobiq.io/ru/components/sidepanel/api
+
+ https://koobiq.io/en/components/sidepanel/examples
+
+
+ https://koobiq.io/ru/components/sidepanel/examples
+
https://koobiq.io/en/components/skeleton/overview
@@ -744,6 +876,12 @@
https://koobiq.io/ru/components/tabs/api
+
+ https://koobiq.io/en/components/tabs/examples
+
+
+ https://koobiq.io/ru/components/tabs/examples
+
https://koobiq.io/en/components/tag/overview
@@ -768,6 +906,12 @@
https://koobiq.io/ru/components/tag-autocomplete/api
+
+ https://koobiq.io/en/components/tag-autocomplete/examples
+
+
+ https://koobiq.io/ru/components/tag-autocomplete/examples
+
https://koobiq.io/en/components/tag-input/overview
@@ -780,6 +924,12 @@
https://koobiq.io/ru/components/tag-input/api
+
+ https://koobiq.io/en/components/tag-input/examples
+
+
+ https://koobiq.io/ru/components/tag-input/examples
+
https://koobiq.io/en/components/tag-list/overview
@@ -816,6 +966,12 @@
https://koobiq.io/ru/components/time-range/api
+
+ https://koobiq.io/en/components/time-range/examples
+
+
+ https://koobiq.io/ru/components/time-range/examples
+
https://koobiq.io/en/components/timepicker/overview
@@ -864,6 +1020,12 @@
https://koobiq.io/ru/components/toast/api
+
+ https://koobiq.io/en/components/toast/examples
+
+
+ https://koobiq.io/ru/components/toast/examples
+
https://koobiq.io/en/components/toggle/overview
@@ -888,6 +1050,12 @@
https://koobiq.io/ru/components/tooltip/api
+
+ https://koobiq.io/en/components/tooltip/examples
+
+
+ https://koobiq.io/ru/components/tooltip/examples
+
https://koobiq.io/en/components/top-bar/overview
@@ -912,6 +1080,12 @@
https://koobiq.io/ru/components/tree/api
+
+ https://koobiq.io/en/components/tree/examples
+
+
+ https://koobiq.io/ru/components/tree/examples
+
https://koobiq.io/en/components/tree-select/overview
@@ -924,6 +1098,12 @@
https://koobiq.io/ru/components/tree-select/api
+
+ https://koobiq.io/en/components/tree-select/examples
+
+
+ https://koobiq.io/ru/components/tree-select/examples
+
https://koobiq.io/en/components/username/overview
@@ -942,6 +1122,12 @@
https://koobiq.io/ru/other/date-formatter/overview
+
+ https://koobiq.io/en/other/date-formatter/examples
+
+
+ https://koobiq.io/ru/other/date-formatter/examples
+
https://koobiq.io/en/other/filesize-formatter/overview
@@ -966,6 +1152,12 @@
https://koobiq.io/ru/other/validation/overview
+
+ https://koobiq.io/en/other/validation/examples
+
+
+ https://koobiq.io/ru/other/validation/examples
+
https://koobiq.io/en/icons
diff --git a/packages/components/core/styles/layout-flex.en.md b/packages/components/core/styles/layout-flex.en.md
index 257202cb89..08e80af618 100644
--- a/packages/components/core/styles/layout-flex.en.md
+++ b/packages/components/core/styles/layout-flex.en.md
@@ -1,3 +1,5 @@
+Layout flex provides Koobiq CSS classes for arranging, aligning, and ordering elements in flex containers.
+
### Alignment
diff --git a/packages/components/core/styles/layout-flex.ru.md b/packages/components/core/styles/layout-flex.ru.md
index 257202cb89..e598f49e33 100644
--- a/packages/components/core/styles/layout-flex.ru.md
+++ b/packages/components/core/styles/layout-flex.ru.md
@@ -1,3 +1,5 @@
+Layout flex — набор CSS-классов Koobiq для компоновки, выравнивания и упорядочивания элементов во flex-контейнерах.
+
### Alignment
diff --git a/packages/components/dl/dl.en.md b/packages/components/dl/dl.en.md
index ea569c267c..47a54d25fd 100644
--- a/packages/components/dl/dl.en.md
+++ b/packages/components/dl/dl.en.md
@@ -1,3 +1,5 @@
+Description list displays term-description pairs in adaptive, horizontal, or vertical layouts.
+
### Default (adaptive)
diff --git a/packages/components/dl/dl.ru.md b/packages/components/dl/dl.ru.md
index ea569c267c..8e2fa11e20 100644
--- a/packages/components/dl/dl.ru.md
+++ b/packages/components/dl/dl.ru.md
@@ -1,3 +1,5 @@
+Description list отображает пары терминов и описаний в адаптивном, горизонтальном или вертикальном формате.
+
### Default (adaptive)
diff --git a/packages/components/list/list.en.md b/packages/components/list/list.en.md
index e8eeb7d200..155890ee6a 100644
--- a/packages/components/list/list.en.md
+++ b/packages/components/list/list.en.md
@@ -1,3 +1,5 @@
+List supports groups, single or multiple selection, keyboard navigation, and virtual scrolling.
+
#### With default parameters (autoselect="true", no-unselect="true")
diff --git a/packages/components/list/list.ru.md b/packages/components/list/list.ru.md
index e8eeb7d200..db8103f2b5 100644
--- a/packages/components/list/list.ru.md
+++ b/packages/components/list/list.ru.md
@@ -1,3 +1,5 @@
+List отображает списки с группами, одиночным или множественным выбором, клавиатурной навигацией и виртуальной прокруткой.
+
#### With default parameters (autoselect="true", no-unselect="true")
diff --git a/packages/components/splitter/splitter.en.md b/packages/components/splitter/splitter.en.md
index 4224efe36c..034fa05b7e 100644
--- a/packages/components/splitter/splitter.en.md
+++ b/packages/components/splitter/splitter.en.md
@@ -1,3 +1,5 @@
+Splitter divides an area into resizable horizontal or vertical panels and supports nested layouts.
+
#### With default parameters
diff --git a/packages/components/splitter/splitter.ru.md b/packages/components/splitter/splitter.ru.md
index 4224efe36c..2f44f71a86 100644
--- a/packages/components/splitter/splitter.ru.md
+++ b/packages/components/splitter/splitter.ru.md
@@ -1,3 +1,5 @@
+Splitter разделяет область на изменяемые по размеру горизонтальные или вертикальные панели и поддерживает вложенные компоновки.
+
#### With default parameters
diff --git a/tools/generate-prerender-routes.ts b/tools/generate-prerender-routes.ts
index f21404ed8a..4013a34389 100644
--- a/tools/generate-prerender-routes.ts
+++ b/tools/generate-prerender-routes.ts
@@ -1,13 +1,7 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import { DOCS_SUPPORTED_LOCALES } from '../apps/docs/src/app/constants/locale';
-import {
- docsGetItems,
- DocsStructureCategoryId,
- DocsStructureItemId,
- DocsStructureItemTab,
- DocsStructureTokensTab
-} from '../apps/docs/src/app/structure';
+import { docsGetIndexablePagePaths } from '../apps/docs/src/app/page-paths';
const TIME_LABEL = 'Runtime';
const FILE_NAME = 'prerender-routes.txt';
@@ -17,29 +11,9 @@ console.time(TIME_LABEL);
try {
console.info(`🚀 Generating ${FILE_NAME}`);
- const paths = docsGetItems()
- .map(({ categoryId, id, hasApi, hasExamples }) => {
- // We should manually handle /design-tokens page, because it has a different tab structure.
- if (id === DocsStructureItemId.DesignTokens) {
- return Object.values(DocsStructureTokensTab).map((tab) => `${categoryId}/${id}/${tab}`);
- }
-
- const tabs = [`${categoryId}/${id}/${DocsStructureItemTab.Overview}`];
-
- if (hasApi) tabs.push(`${categoryId}/${id}/${DocsStructureItemTab.Api}`);
- if (hasExamples) tabs.push(`${categoryId}/${id}/${DocsStructureItemTab.Examples}`);
-
- return tabs;
- })
- .flat();
-
- // We should manually add the icons path, because it does not have any items.
- paths.push(`${DocsStructureCategoryId.Icons}`);
-
- const routes = DOCS_SUPPORTED_LOCALES.flatMap((locale) => [
- `/${locale}`,
- ...paths.map((path) => `/${locale}/${path}`)
- ]);
+ const routes = DOCS_SUPPORTED_LOCALES.flatMap((locale) =>
+ docsGetIndexablePagePaths().map((path) => `/${locale}${path ? `/${path}` : ''}`)
+ );
writeFileSync(join(process.cwd(), `apps/docs/src/${FILE_NAME}`), routes.join('\n') + '\n');
diff --git a/tools/generate-sitemap.ts b/tools/generate-sitemap.ts
index 747b9b323a..8d57c350bd 100644
--- a/tools/generate-sitemap.ts
+++ b/tools/generate-sitemap.ts
@@ -1,13 +1,7 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import { DOCS_SUPPORTED_LOCALES } from '../apps/docs/src/app/constants/locale';
-import {
- docsGetItems,
- DocsStructureCategoryId,
- DocsStructureItemId,
- DocsStructureItemTab,
- DocsStructureTokensTab
-} from '../apps/docs/src/app/structure';
+import { docsGetIndexablePagePaths } from '../apps/docs/src/app/page-paths';
const timeLabel = 'Runtime';
@@ -16,27 +10,9 @@ console.time(timeLabel);
try {
console.info('🚀 Generating sitemap.xml');
- const paths = docsGetItems()
- .map(({ categoryId, id, hasApi }) => {
- // We should manually handle /design-tokens page, because it has a different tab structure.
- if (id === DocsStructureItemId.DesignTokens) {
- return Object.values(DocsStructureTokensTab).map((tab) => `${categoryId}/${id}/${tab}`);
- }
-
- const tabs = [`${categoryId}/${id}/${DocsStructureItemTab.Overview}`];
-
- if (hasApi) tabs.push(`${categoryId}/${id}/${DocsStructureItemTab.Api}`);
-
- return tabs;
- })
- .flat();
-
- // We should manually add the icons path, because it does not have any items.
- paths.push(`${DocsStructureCategoryId.Icons}`);
-
- const routes = paths
+ const routes = docsGetIndexablePagePaths()
.map((path) => {
- return DOCS_SUPPORTED_LOCALES.map((locale) => `https://koobiq.io/${locale}/${path}`);
+ return DOCS_SUPPORTED_LOCALES.map((locale) => `https://koobiq.io/${locale}${path ? `/${path}` : ''}`);
})
.flat();
diff --git a/tools/markdown-to-html/generate-seo-descriptions.ts b/tools/markdown-to-html/generate-seo-descriptions.ts
new file mode 100644
index 0000000000..b2fef7c927
--- /dev/null
+++ b/tools/markdown-to-html/generate-seo-descriptions.ts
@@ -0,0 +1,49 @@
+import { readFile, writeFile } from 'fs/promises';
+import { basename, join } from 'path';
+import { extractSeoDescription } from './seo-description';
+import { src } from './utils';
+
+const GENERATED_FILE = 'apps/docs/src/app/seo-descriptions.ts';
+const LOCALIZED_MARKDOWN_FILE = /^(?.+)\.(?en|ru)\.md$/;
+
+type DocsSeoDescriptions = Record>>;
+
+/** Generates the static description registry consumed synchronously during routing and SSG. */
+export const generateSeoDescriptions = async (sourcePatterns: string | string[]): Promise => {
+ const descriptions: DocsSeoDescriptions = {};
+
+ for (const inputPath of src(sourcePatterns)) {
+ const match = basename(inputPath).match(LOCALIZED_MARKDOWN_FILE);
+
+ if (!match?.groups) continue;
+
+ const { id, locale } = match.groups as { id: string; locale: 'en' | 'ru' };
+ const description = extractSeoDescription(await readFile(inputPath, 'utf8'));
+
+ if (!description) continue;
+
+ descriptions[id] ??= {};
+
+ if (descriptions[id][locale]) {
+ throw new Error(`Duplicate SEO description for ${id}.${locale}: ${inputPath}`);
+ }
+
+ descriptions[id][locale] = description;
+ }
+
+ const sortedDescriptions = Object.fromEntries(
+ Object.entries(descriptions)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([id, localized]) => [id, Object.fromEntries(Object.entries(localized).sort())])
+ );
+ const output = [
+ '/**',
+ ' * NOTE! Do not edit manually. Generated from the first paragraph of localized overview Markdown.',
+ ' * Run `yarn run build:docs-content` to update.',
+ ' */',
+ `export const DOCS_SEO_DESCRIPTIONS = ${JSON.stringify(sortedDescriptions, null, 4)} as const;`,
+ ''
+ ].join('\n');
+
+ await writeFile(join(process.cwd(), GENERATED_FILE), output);
+};
diff --git a/tools/markdown-to-html/seo-description.spec.ts b/tools/markdown-to-html/seo-description.spec.ts
new file mode 100644
index 0000000000..421b89b429
--- /dev/null
+++ b/tools/markdown-to-html/seo-description.spec.ts
@@ -0,0 +1,26 @@
+import { extractSeoDescription } from './seo-description';
+
+describe(extractSeoDescription.name, () => {
+ it('returns visible plain text from the first paragraph', () => {
+ expect(
+ extractSeoDescription(
+ 'Use the [`KbqButton`](https://example.com) component with `