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<HTMLLinkElement>('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<T> = Partial<Record<DocsLocale, T>>; + +export type DocsSeoMeta = { + title?: DocsLocalizedSeoValue<string>; + description?: DocsLocalizedSeoValue<string>; + image?: string; + imageAlt?: DocsLocalizedSeoValue<string>; + keywords?: DocsLocalizedSeoValue<readonly string[]>; + noIndex?: boolean; +}; + +export type DocsItemSeoMeta = DocsSeoMeta & { + tabs?: Partial<Record<DocsStructureItemTab | DocsStructureTokensTab, DocsSeoMeta>>; +}; + export type DocsStructureItem = { id: DocsStructureItemId; name: Record<DocsLocale, string>; @@ -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 @@ <?xml version="1.0" encoding="UTF-8" ?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <loc>https://koobiq.io/en</loc> + </url> + <url> + <loc>https://koobiq.io/ru</loc> + </url> <url> <loc>https://koobiq.io/en/main/installation/overview</loc> </url> @@ -102,6 +108,12 @@ <url> <loc>https://koobiq.io/ru/components/actions-panel/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/actions-panel/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/actions-panel/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/ag-grid/overview</loc> </url> @@ -168,6 +180,12 @@ <url> <loc>https://koobiq.io/ru/components/breadcrumbs/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/breadcrumbs/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/breadcrumbs/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/button/overview</loc> </url> @@ -216,6 +234,12 @@ <url> <loc>https://koobiq.io/ru/components/checkbox/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/checkbox/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/checkbox/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/clamped-list/overview</loc> </url> @@ -252,6 +276,12 @@ <url> <loc>https://koobiq.io/ru/components/code-block/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/code-block/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/code-block/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/content-panel/overview</loc> </url> @@ -336,6 +366,12 @@ <url> <loc>https://koobiq.io/ru/components/dynamic-translation/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/dynamic-translation/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/dynamic-translation/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/empty-state/overview</loc> </url> @@ -360,6 +396,12 @@ <url> <loc>https://koobiq.io/ru/components/file-upload/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/file-upload/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/file-upload/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/filter-bar/overview</loc> </url> @@ -372,6 +414,12 @@ <url> <loc>https://koobiq.io/ru/components/filter-bar/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/filter-bar/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/filter-bar/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/flag/overview</loc> </url> @@ -450,6 +498,12 @@ <url> <loc>https://koobiq.io/ru/components/inline-edit/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/inline-edit/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/inline-edit/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/input/overview</loc> </url> @@ -462,6 +516,12 @@ <url> <loc>https://koobiq.io/ru/components/input/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/input/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/input/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/layout-flex/overview</loc> </url> @@ -480,6 +540,12 @@ <url> <loc>https://koobiq.io/ru/components/link/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/link/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/link/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/list/overview</loc> </url> @@ -492,6 +558,12 @@ <url> <loc>https://koobiq.io/ru/components/list/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/list/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/list/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/markdown/overview</loc> </url> @@ -504,6 +576,12 @@ <url> <loc>https://koobiq.io/ru/components/markdown/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/markdown/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/markdown/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/modal/overview</loc> </url> @@ -516,6 +594,12 @@ <url> <loc>https://koobiq.io/ru/components/modal/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/modal/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/modal/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/navbar/overview</loc> </url> @@ -528,6 +612,12 @@ <url> <loc>https://koobiq.io/ru/components/navbar/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/navbar/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/navbar/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/notification-center/overview</loc> </url> @@ -540,6 +630,12 @@ <url> <loc>https://koobiq.io/ru/components/notification-center/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/notification-center/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/notification-center/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/overflow-items/overview</loc> </url> @@ -552,6 +648,12 @@ <url> <loc>https://koobiq.io/ru/components/overflow-items/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/overflow-items/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/overflow-items/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/loader-overlay/overview</loc> </url> @@ -576,6 +678,12 @@ <url> <loc>https://koobiq.io/ru/components/popover/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/popover/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/popover/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/progress-bar/overview</loc> </url> @@ -636,6 +744,12 @@ <url> <loc>https://koobiq.io/ru/components/scrollbar/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/scrollbar/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/scrollbar/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/search-expandable/overview</loc> </url> @@ -660,6 +774,12 @@ <url> <loc>https://koobiq.io/ru/components/select/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/select/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/select/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/sidebar/overview</loc> </url> @@ -672,6 +792,12 @@ <url> <loc>https://koobiq.io/ru/components/sidebar/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/sidebar/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/sidebar/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/sidepanel/overview</loc> </url> @@ -684,6 +810,12 @@ <url> <loc>https://koobiq.io/ru/components/sidepanel/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/sidepanel/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/sidepanel/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/skeleton/overview</loc> </url> @@ -744,6 +876,12 @@ <url> <loc>https://koobiq.io/ru/components/tabs/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/tabs/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/tabs/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/tag/overview</loc> </url> @@ -768,6 +906,12 @@ <url> <loc>https://koobiq.io/ru/components/tag-autocomplete/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/tag-autocomplete/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/tag-autocomplete/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/tag-input/overview</loc> </url> @@ -780,6 +924,12 @@ <url> <loc>https://koobiq.io/ru/components/tag-input/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/tag-input/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/tag-input/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/tag-list/overview</loc> </url> @@ -816,6 +966,12 @@ <url> <loc>https://koobiq.io/ru/components/time-range/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/time-range/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/time-range/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/timepicker/overview</loc> </url> @@ -864,6 +1020,12 @@ <url> <loc>https://koobiq.io/ru/components/toast/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/toast/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/toast/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/toggle/overview</loc> </url> @@ -888,6 +1050,12 @@ <url> <loc>https://koobiq.io/ru/components/tooltip/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/tooltip/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/tooltip/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/top-bar/overview</loc> </url> @@ -912,6 +1080,12 @@ <url> <loc>https://koobiq.io/ru/components/tree/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/tree/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/tree/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/tree-select/overview</loc> </url> @@ -924,6 +1098,12 @@ <url> <loc>https://koobiq.io/ru/components/tree-select/api</loc> </url> + <url> + <loc>https://koobiq.io/en/components/tree-select/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/components/tree-select/examples</loc> + </url> <url> <loc>https://koobiq.io/en/components/username/overview</loc> </url> @@ -942,6 +1122,12 @@ <url> <loc>https://koobiq.io/ru/other/date-formatter/overview</loc> </url> + <url> + <loc>https://koobiq.io/en/other/date-formatter/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/other/date-formatter/examples</loc> + </url> <url> <loc>https://koobiq.io/en/other/filesize-formatter/overview</loc> </url> @@ -966,6 +1152,12 @@ <url> <loc>https://koobiq.io/ru/other/validation/overview</loc> </url> + <url> + <loc>https://koobiq.io/en/other/validation/examples</loc> + </url> + <url> + <loc>https://koobiq.io/ru/other/validation/examples</loc> + </url> <url> <loc>https://koobiq.io/en/icons</loc> </url> 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. + <!-- example(layout-flex-overview) --> ### 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-контейнерах. + <!-- example(layout-flex-overview) --> ### 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) <!-- example(dl-overview) --> 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) <!-- example(dl-overview) --> 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") <!-- example(list-overview) --> 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") <!-- example(list-overview) --> 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 <!-- example(splitter-overview) --> 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 <!-- example(splitter-overview) --> 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 = /^(?<id>.+)\.(?<locale>en|ru)\.md$/; + +type DocsSeoDescriptions = Record<string, Partial<Record<'en' | 'ru', string>>>; + +/** Generates the static description registry consumed synchronously during routing and SSG. */ +export const generateSeoDescriptions = async (sourcePatterns: string | string[]): Promise<void> => { + 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 `<button>` elements.\n\n### Next section' + ) + ).toBe('Use the KbqButton component with button elements.'); + }); + + it('returns null when a document has no paragraph', () => { + expect(extractSeoDescription('### Section')).toBeNull(); + }); + + it('decodes each HTML entity only once', () => { + expect(extractSeoDescription('&quot; &lt; " <')).toBe('" < "'); + }); + + it('truncates long descriptions without splitting a word', () => { + const description = extractSeoDescription('word '.repeat(60)); + + expect(description!.length).toBeLessThanOrEqual(200); + expect(description!.endsWith('…')).toBe(true); + }); +}); diff --git a/tools/markdown-to-html/seo-description.ts b/tools/markdown-to-html/seo-description.ts new file mode 100644 index 0000000000..23e5f9ef39 --- /dev/null +++ b/tools/markdown-to-html/seo-description.ts @@ -0,0 +1,43 @@ +import { marked, Token, Tokens } from 'marked'; + +const MAX_DESCRIPTION_LENGTH = 200; + +const inlineTokenText = (token: Token): string => { + if (token.type === 'br' || token.type === 'html') return ' '; + + return 'text' in token && typeof token.text === 'string' ? token.text : ''; +}; + +const normalizeDescription = (description: string): string => { + return description + .replace(/ |\u00a0/g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&#(?:39|x27);/gi, "'") + .replace(/&/g, '&') + .replace(/[`*_~]/g, '') + .replace(/[<>]/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +const truncateDescription = (description: string): string => { + if (description.length <= MAX_DESCRIPTION_LENGTH) return description; + + const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH - 1); + const lastSpace = truncated.lastIndexOf(' '); + + return `${truncated.slice(0, lastSpace > 0 ? lastSpace : undefined)}…`; +}; + +/** Returns the first user-facing Markdown paragraph as plain text suitable for a meta description. */ +export const extractSeoDescription = (markdown: string): string | null => { + const paragraph = marked.lexer(markdown).find((token): token is Tokens.Paragraph => token.type === 'paragraph'); + + if (!paragraph) return null; + + const description = normalizeDescription(paragraph.tokens.map(inlineTokenText).join('')); + + return description ? truncateDescription(description) : null; +}; diff --git a/tools/markdown-to-html/transform-markdown.ts b/tools/markdown-to-html/transform-markdown.ts index 77b05b73b2..10c5c84b61 100644 --- a/tools/markdown-to-html/transform-markdown.ts +++ b/tools/markdown-to-html/transform-markdown.ts @@ -2,17 +2,20 @@ * Script that will be used to transform * multiple markdown files into the equivalent HTML output. */ +import { generateSeoDescriptions } from './generate-seo-descriptions'; import { docTask } from './utils'; +const overviewSources = [ + 'packages/components/**/!(README|examples*).md', + 'packages/components-experimental/**/!(README|examples*).md', + 'docs/guides/**/*.md', + 'docs/data-grid/**/*.md' +]; + const docsContent = async () => { for (const task of [ docTask('docs-content-overviews', { - source: [ - 'packages/components/**/!(README|examples*).md', - 'packages/components-experimental/**/!(README|examples*).md', - 'docs/guides/**/*.md', - 'docs/data-grid/**/*.md' - ], + source: overviewSources, dest: 'dist/docs-content/overviews' }), @@ -26,6 +29,8 @@ const docsContent = async () => { ]) { await task(); } + + await generateSeoDescriptions(overviewSources); }; docsContent();