starlight-seo 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ru.md ADDED
@@ -0,0 +1,462 @@
1
+ <div align="center">
2
+
3
+ # starlight-seo
4
+
5
+ **Заголовки под поиск, связанный JSON-LD, полный набор Open Graph и проверка SEO при сборке для сайтов документации на [Starlight](https://starlight.astro.build/).**
6
+
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/kaktaknet/starlight-seo/ci.yml?branch=main&label=CI)](https://github.com/kaktaknet/starlight-seo/actions/workflows/ci.yml)
8
+ [![Выпуск](https://img.shields.io/github/v/tag/kaktaknet/starlight-seo?label=%D0%B2%D1%8B%D0%BF%D1%83%D1%81%D0%BA&sort=semver)](https://github.com/kaktaknet/starlight-seo/tags)
9
+ [![Лицензия: MIT](https://img.shields.io/github/license/kaktaknet/starlight-seo?label=%D0%BB%D0%B8%D1%86%D0%B5%D0%BD%D0%B7%D0%B8%D1%8F)](./LICENSE)
10
+ [![Starlight](https://img.shields.io/badge/Starlight-%E2%89%A5%200.32-7c3aed)](https://starlight.astro.build/)
11
+ [![Astro](https://img.shields.io/badge/Astro-%E2%89%A5%205-ff5d01)](https://astro.build/)
12
+ [![Node](https://img.shields.io/badge/Node-%E2%89%A5%2020-339933)](https://nodejs.org/)
13
+ [![Зависимости](https://img.shields.io/badge/%D0%B7%D0%B0%D0%B2%D0%B8%D1%81%D0%B8%D0%BC%D0%BE%D1%81%D1%82%D0%B8-0-brightgreen)](./package.json)
14
+
15
+ [English](./README.md) · **Русский**
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## Коротко
22
+
23
+ | | |
24
+ |---|---|
25
+ | **Что это** | Плагин для Starlight. Одна строка в `astro.config`, одна строка в `content.config`. |
26
+ | **Проблема** | В Starlight одно поле `title` служит пунктом бокового меню, заголовком `<h1>`, тегом `<title>` и `og:title`. Хорошее название для меню («Git», «Docker», «Установка») - плохой результат в поиске. |
27
+ | **Решение** | Меню и заголовок страницы сохраняют короткое название. Поисковики и карточки в соцсетях получают полный заголовок из `seo.title` или из шаблона раздела. |
28
+ | **Ещё** | Один граф JSON-LD на страницу, хлебные крошки из бокового меню, достройка Open Graph и проверка, которая останавливает сборку при слабых метаданных. |
29
+ | **Как устроен** | Обработчик маршрута Starlight. Компоненты не подменяются, зависимостей нет, шага сборки нет. |
30
+ | **Сделан для** | Starlight **0.42** на Astro **7** - разработан и проверен на 0.42.5 и 7.3.5. |
31
+ | **Работает с** | Starlight ≥ 0.32, Astro ≥ 5, Node ≥ 20. В настройках Astro должен быть задан `site`. |
32
+
33
+ ```diff
34
+ - <title>Git | MCP Doc</title>
35
+ + <title>MCP-сервер Git: инструменты, запуск и известные уязвимости | MCP Doc</title>
36
+ ```
37
+
38
+ В меню по-прежнему **Git**. В `<h1>` по-прежнему **Git**.
39
+
40
+ <details>
41
+ <summary><b>Для ИИ-агентов: весь репозиторий в четырнадцати строках</b></summary>
42
+
43
+ ```text
44
+ package starlight-seo (ESM, plain JavaScript + hand-written .d.ts, zero dependencies)
45
+ versions built for Starlight 0.42.x + Astro 7.x (tested 0.42.5 / 7.3.5); floor Starlight 0.32, Astro 5, Node 20
46
+ install pnpm add github:kaktaknet/starlight-seo#v0.2.0 -> plugins: [starlightSeo()] -> docsSchema({ extend: seoSchema() })
47
+ entry index.js default export starlightSeo(options) -> Starlight plugin
48
+ schema schema.js seoSchema() -> pass to docsSchema({ extend })
49
+ middleware middleware.js runs after Starlight, rewrites route.head, pushes JSON-LD
50
+ core core.js pure functions for unit tests: normalize, resolvePage, graphOf, applyHead, serialize
51
+ lib/ title.js breadcrumbs.js graph.js head.js page.js options.js audit.js text.js
52
+ frontmatter seo: { title, description, type, image, imageAlt, noindex, published, modified, section, keywords, about }
53
+ title order seo.title -> first matching title.templates entry -> page title; site name appended only if it fits title.max
54
+ graph WebSite, Organization|Person, WebPage, [ImageObject], [article type], BreadcrumbList, linked by @id
55
+ hooks options.extend -> module exporting page(page, ctx) and graph(nodes, page, ctx)
56
+ audit astro:build:done, reads built HTML, 14 rules, throws on level "error"
57
+ tests pnpm test (node --test, unit) · pnpm test:fixture (builds tests/fixture with real Starlight)
58
+ ```
59
+
60
+ Рабочие инструкции для агентов - в [AGENTS.md](./AGENTS.md) (на английском).
61
+
62
+ </details>
63
+
64
+ ## Содержание
65
+
66
+ - [Установка](#установка)
67
+ - [Вручную](#вручную)
68
+ - [С помощью ИИ-агента](#с-помощью-ии-агента)
69
+ - [Как убедиться, что всё работает](#как-убедиться-что-всё-работает)
70
+ - [Заголовки](#заголовки)
71
+ - [Поля frontmatter](#поля-frontmatter)
72
+ - [Структурированные данные](#структурированные-данные)
73
+ - [Open Graph и robots](#open-graph-и-robots)
74
+ - [Непереведённые страницы](#непереведённые-страницы)
75
+ - [Проверка при сборке](#проверка-при-сборке)
76
+ - [Настройки](#настройки)
77
+ - [Совместимость](#совместимость)
78
+ - [Откуда взяты приёмы](#откуда-взяты-приёмы)
79
+ - [Разработка](#разработка)
80
+
81
+ ## Установка
82
+
83
+ Пакет ставится из GitHub. Указывайте метку выпуска, чтобы сборка оставалась воспроизводимой.
84
+
85
+ ### Вручную
86
+
87
+ **1. Добавьте пакет.**
88
+
89
+ ```sh
90
+ pnpm add github:kaktaknet/starlight-seo#v0.2.0
91
+ ```
92
+
93
+ <details>
94
+ <summary>npm и yarn</summary>
95
+
96
+ ```sh
97
+ npm install github:kaktaknet/starlight-seo#v0.2.0
98
+ yarn add starlight-seo@github:kaktaknet/starlight-seo#v0.2.0
99
+ ```
100
+
101
+ </details>
102
+
103
+ **2. Подключите плагин.** Поле `site` обязательно: каноническим адресам и идентификаторам JSON-LD нужен полный адрес сайта.
104
+
105
+ ```js
106
+ // astro.config.mjs
107
+ import { defineConfig } from 'astro/config'
108
+ import starlight from '@astrojs/starlight'
109
+ import starlightSeo from 'starlight-seo'
110
+
111
+ export default defineConfig({
112
+ site: 'https://docs.example.com',
113
+ integrations: [
114
+ starlight({
115
+ title: 'Example Docs',
116
+ plugins: [
117
+ starlightSeo({
118
+ publisher: { name: 'Example Inc.', url: 'https://example.com/', logo: 'https://example.com/logo.png' },
119
+ image: '/og.png',
120
+ }),
121
+ ],
122
+ }),
123
+ ],
124
+ })
125
+ ```
126
+
127
+ **3. Добавьте поля frontmatter в коллекцию документации.**
128
+
129
+ ```ts
130
+ // src/content.config.ts
131
+ import { defineCollection } from 'astro:content'
132
+ import { docsLoader } from '@astrojs/starlight/loaders'
133
+ import { docsSchema } from '@astrojs/starlight/schema'
134
+ import { seoSchema } from 'starlight-seo/schema'
135
+
136
+ export const collections = {
137
+ docs: defineCollection({ loader: docsLoader(), schema: docsSchema({ extend: seoSchema() }) }),
138
+ }
139
+ ```
140
+
141
+ Если коллекция уже расширяет схему, объедините их: `seoSchema().extend({ ...вашиПоля })`.
142
+
143
+ **4. Положите картинку 1200 × 630 в `public/og.png`** или уберите настройку `image` и выключите правило проверки: `'image.missing': 'off'`.
144
+
145
+ **5. Соберите сайт.** Если у проекта есть своя команда сборки, используйте её. Проверка покажет, что стоит улучшить.
146
+
147
+ ```sh
148
+ pnpm astro build
149
+ ```
150
+
151
+ Теперь у каждой страницы есть граф JSON-LD, хлебные крошки и картинка для соцсетей. Заголовки улучшаются по мере того, как вы добавляете `seo.title` страницам или `title.templates` в настройки.
152
+
153
+ Что меняется на страницах сразу после установки: один граф JSON-LD с идентификаторами из раздела [Структурированные данные](#структурированные-данные), мета-тег `robots` с директивами сниппета, `og:type` со значением `website` на страницах, которые не являются статьями, и крошки, построенные по боковому меню. Тестам, которые проверяют прежний вид метаданных, нужны новые идентификаторы.
154
+
155
+ > [!IMPORTANT]
156
+ > Если сайт уже сам пишет JSON-LD, `og:image` или `<title>` в подменённом компоненте `Head` или в обработчике маршрута, удалите этот код. Два источника дают повторяющиеся теги, и правило `jsonld.mismatch` сообщит о расхождении.
157
+
158
+ ### С помощью ИИ-агента
159
+
160
+ Дайте агенту эту задачу из корня проекта на Starlight:
161
+
162
+ ```text
163
+ Install the Starlight plugin https://github.com/kaktaknet/starlight-seo into this project.
164
+ Read its AGENTS.md first and follow the section "Installing into a site" step by step:
165
+ https://raw.githubusercontent.com/kaktaknet/starlight-seo/main/AGENTS.md
166
+ Use the package manager this repository already uses. Do not invent option names:
167
+ take them only from the README. Finish by running the build and report the audit output.
168
+ ```
169
+
170
+ В [AGENTS.md](./AGENTS.md) те же шаги записаны так, чтобы агент мог их выполнить и проверить: что определить сначала, какие файлы править, что удалить и какими командами подтвердить результат. Там же указано, для каких версий Starlight и Astro сделан плагин. [CLAUDE.md](./CLAUDE.md) направляет Claude Code в тот же файл.
171
+
172
+ ### Как убедиться, что всё работает
173
+
174
+ ```sh
175
+ pnpm astro build
176
+ grep -o '<title>[^<]*</title>' dist/index.html
177
+ grep -c 'application/ld+json' dist/index.html
178
+ ```
179
+
180
+ Ожидаемый результат: сборка заканчивается строкой `[starlight-seo] audited N page(s): 0 error(s), ...`, заголовок напечатан, счётчик равен `1`.
181
+
182
+ ## Заголовки
183
+
184
+ Заголовок выбирается в таком порядке:
185
+
186
+ 1. `seo.title` во frontmatter страницы.
187
+ 2. Первая подошедшая запись из `title.templates`.
188
+ 3. Поле `title` страницы.
189
+
190
+ ```md
191
+ ---
192
+ title: Git
193
+ description: Эталонный сервер Git - 12 инструментов, команда запуска и четыре исправленные уязвимости.
194
+ seo:
195
+ title: 'MCP-сервер Git: инструменты, запуск и известные уязвимости'
196
+ ---
197
+ ```
198
+
199
+ ```js
200
+ starlightSeo({
201
+ title: {
202
+ templates: [
203
+ { match: '/sdk/*/', template: 'MCP {title}: установка, версии и примеры' },
204
+ { match: '/servers/*/', template: { en: '{title} MCP server', ru: 'MCP-сервер {title}' } },
205
+ ],
206
+ },
207
+ })
208
+ ```
209
+
210
+ `{title}` - название страницы, `{site}` - название сайта. Шаблоны сравниваются с путём без префикса языка: `*` соответствует одному сегменту, `**` - любой глубине.
211
+
212
+ Сайту, который уже хранит заголовки в одном месте, frontmatter не нужен. Шаблон без `{title}` с точным путём - это заголовок одной страницы, а функция `page` может вернуть `title` из любого источника данных:
213
+
214
+ ```js
215
+ title: { templates: [{ match: '/faq/', template: 'Вопросы и ответы об Example API' }] }
216
+ ```
217
+
218
+ Название сайта дописывается (`Заголовок | Сайт`), только пока результат помещается в `title.max`. Заголовок, в котором название сайта уже есть, не меняется. В `og:title` и JSON-LD заголовок всегда идёт без названия сайта.
219
+
220
+ Длинное название сайта вместе с `brand: 'always'` выводит большинство заголовков за `title.max`. Можно поднять `title.max`, задать более короткий хвост через `title.site` или оставить `'auto'`: тогда длинные заголовки идут без названия сайта.
221
+
222
+ | Настройка | По умолчанию | Смысл |
223
+ |---|---|---|
224
+ | `title.max` | `60` | наибольшая длина полного заголовка; она же предел для дописывания названия сайта |
225
+ | `title.min` | `30` | наименьшая допустимая длина, используется проверкой |
226
+ | `title.brand` | `'auto'` | `'auto'` дописывает название сайта, если оно помещается; `'always'`; `'never'` |
227
+ | `title.delimiter` | `titleDelimiter` из Starlight | разделитель перед названием сайта |
228
+ | `title.site` | название сайта | текст, который дописывается к заголовкам, если он должен отличаться от имени `WebSite` |
229
+ | `title.templates` | `[]` | записи `{ match, template }`, действует первая подошедшая |
230
+
231
+ ## Поля frontmatter
232
+
233
+ Все поля необязательны и находятся внутри `seo`.
234
+
235
+ | Поле | Смысл |
236
+ |---|---|
237
+ | `title` | заголовок для поиска; меню и `<h1>` берут `title` |
238
+ | `description` | заменяет `description` в мета-тегах и JSON-LD |
239
+ | `type` | тип страницы по schema.org, например `FAQPage` или `BlogPosting` |
240
+ | `image`, `imageAlt` | картинка для соцсетей у этой страницы |
241
+ | `noindex` | выводит `noindex, follow` и исключает страницу из проверки |
242
+ | `published`, `modified` | даты; `modified` по умолчанию берётся из `lastUpdated` Starlight |
243
+ | `section`, `keywords` | `articleSection` и `keywords` статьи |
244
+ | `about` | о чём страница: название или `{ name, sameAs, url, type }`, одно или несколько |
245
+
246
+ Страницы, собранные через `<StarlightPage>`, принимают те же поля:
247
+
248
+ ```astro
249
+ <StarlightPage frontmatter={{ title, description, seo: { type: 'BlogPosting', published } }}>
250
+ ```
251
+
252
+ ## Структурированные данные
253
+
254
+ Каждая страница получает один `@graph`, узлы которого ссылаются друг на друга через `@id`:
255
+
256
+ ```mermaid
257
+ graph LR
258
+ Article["TechArticle<br/>#article"] -- mainEntityOfPage --> WebPage["WebPage<br/>#webpage"]
259
+ Article -- author / publisher --> Org["Organization<br/>#organization"]
260
+ Article -- image --> Image["ImageObject<br/>#primaryimage"]
261
+ WebPage -- isPartOf --> WebSite["WebSite<br/>#website"]
262
+ WebPage -- breadcrumb --> Crumbs["BreadcrumbList<br/>#breadcrumb"]
263
+ WebPage -- primaryImageOfPage --> Image
264
+ WebSite -- publisher --> Org
265
+ ```
266
+
267
+ - `WebSite` и его издатель (`Organization` или `Person`, с логотипом и `sameAs`);
268
+ - `WebPage` с крошками, главной картинкой и датами;
269
+ - для статей - узел статьи (по умолчанию `TechArticle`), который указывает на страницу через `mainEntityOfPage`;
270
+ - `BreadcrumbList`, построенный по боковому меню;
271
+ - `ImageObject`, если у страницы есть картинка для соцсетей.
272
+
273
+ Тип страницы - это `seo.type`, затем первое совпадение в `types`, затем `WebPage` для главной, `CollectionPage` для `template: splash` и `defaultType` (`TechArticle`) для остальных.
274
+
275
+ ```js
276
+ starlightSeo({
277
+ site: {
278
+ description: 'Справочник по Example API',
279
+ about: { name: 'Example API', sameAs: 'https://www.wikidata.org/wiki/Q0' },
280
+ },
281
+ types: [{ match: '/blog/*/', type: 'BlogPosting', section: 'Блог' }],
282
+ breadcrumbs: { groups: 'link', home: 'Главная' },
283
+ })
284
+ ```
285
+
286
+ Идентификаторы узлов постоянны, на них можно опираться в своих функциях и тестах:
287
+
288
+ | Узел | `@id` |
289
+ |---|---|
290
+ | `WebSite` | `<адрес сайта>/#website` |
291
+ | издатель | `publisher.id` или `<publisher.url>#organization` (`#person` для `Person`) |
292
+ | страница | `<канонический адрес>#webpage` |
293
+ | статья | `<канонический адрес>#article` |
294
+ | крошки | `<канонический адрес>#breadcrumb` |
295
+ | картинка | `<канонический адрес>#primaryimage` |
296
+
297
+ `inLanguage` - это `lang` страницы в Starlight. `publisher` и `author` стоят на узле статьи, а не на `WebPage`. Крошка текущей страницы несёт её название из бокового меню; вложенные группы, ведущие на одну страницу, сливаются в одну крошку.
298
+
299
+ `breadcrumbs.groups` определяет, чем станет группа бокового меню: `'link'` (по умолчанию) ведёт на первую страницу группы, `'plain'` оставляет название без адреса, `'skip'` пропускает группы.
300
+
301
+ ### Свои узлы
302
+
303
+ Укажите в `extend` модуль, который экспортирует `page`, `graph` или обе функции. Модуль выполняется на сервере во время отрисовки и может читать ваши данные.
304
+
305
+ ```js
306
+ starlightSeo({ extend: './src/seo.ts' })
307
+ ```
308
+
309
+ ```ts
310
+ // src/seo.ts
311
+ import type { SeoGraphHook, SeoPageHook } from 'starlight-seo'
312
+
313
+ export const page: SeoPageHook = (page) => {
314
+ if (page.path.startsWith('/changelog/')) return { type: 'Article', section: 'Changelog' }
315
+ }
316
+
317
+ export const graph: SeoGraphHook = (nodes, page) => {
318
+ if (page.path !== '/sdk/python/') return nodes
319
+ return [
320
+ ...nodes,
321
+ {
322
+ '@type': 'SoftwareSourceCode',
323
+ '@id': `${page.url}#code`,
324
+ name: 'Python SDK',
325
+ programmingLanguage: 'Python',
326
+ codeRepository: 'https://github.com/example/python-sdk',
327
+ subjectOf: { '@id': `${page.url}#webpage` },
328
+ },
329
+ ]
330
+ }
331
+ ```
332
+
333
+ `page` возвращает поля, которые нужно изменить до записи `<head>`; возвращённый `title` получает название сайта по тому же правилу, что и любой другой заголовок. `graph` возвращает итоговый список узлов и может добавлять, менять и удалять любые из них, включая встроенные.
334
+
335
+ Обработчик плагина выполняется после собственного `routeMiddleware` сайта и видит `<head>`, который сайт уже поправил.
336
+
337
+ ## Open Graph и robots
338
+
339
+ Плагин переписывает `og:title` и `og:description`, ставит `og:type` равным `website` для страниц, которые не являются статьями, и добавляет `og:image` с размерами и описанием, `twitter:image`, а также `article:published_time` и `article:modified_time`. Без картинки `twitter:card` становится `summary`.
340
+
341
+ `image` - это путь, адрес или шаблон с `{slug}`, `{lang}` и `{locale}`. Поля `src` и `alt` принимают запись по языкам:
342
+
343
+ ```js
344
+ starlightSeo({ image: { src: '/og/{slug}.png', width: 1200, height: 630, alt: 'Example Docs' } })
345
+ ```
346
+
347
+ Плагин не рисует картинки. Подойдёт любой генератор, который кладёт файлы по такому шаблону, например [astro-og-canvas](https://github.com/delucis/astro-og-canvas) с маршрутом `src/pages/og/[...slug].ts`. Проверка сообщит о картинке, которой нет в собранном сайте.
348
+
349
+ Мета-тег `robots` со значением `max-snippet:-1, max-image-preview:large, max-video-preview:-1` добавляется, если у страницы нет своего. `robots: false` его отключает, строка задаёт своё значение.
350
+
351
+ ## Непереведённые страницы
352
+
353
+ Если для языка нет перевода, Starlight показывает текст на основном языке по адресу этого языка. При значении по умолчанию `fallback: 'canonical'` такая страница указывает каноническим адресом на исходную, и поисковик не индексирует один текст дважды. `fallback: 'keep'` оставляет канонический адрес таким, каким его записал Starlight.
354
+
355
+ ## Проверка при сборке
356
+
357
+ После `astro build` плагин читает готовый HTML и сообщает о проблемах. Сборка останавливается, если сработало правило уровня `error`.
358
+
359
+ ```text
360
+ [starlight-seo] title.short: 36 page(s)
361
+ [starlight-seo] /deployment/docker/ 16 < 30: Docker | MCP Doc
362
+ [starlight-seo] /deployment/ubuntu/ 15 < 30: Linux | MCP Doc
363
+ [starlight-seo] audited 98 page(s): 0 error(s), 36 warning(s)
364
+ ```
365
+
366
+ | Правило | По умолчанию | Когда срабатывает |
367
+ |---|---|---|
368
+ | `title.missing` | error | нет `<title>` |
369
+ | `title.short` | warn | заголовок короче `title.min` |
370
+ | `title.long` | warn | заголовок длиннее `title.max` |
371
+ | `title.duplicate` | error | у двух страниц один заголовок |
372
+ | `description.missing` | error | нет мета-описания |
373
+ | `description.short` | warn | короче `description.min` (70) |
374
+ | `description.long` | warn | длиннее `description.max` (160) |
375
+ | `description.duplicate` | error | у двух страниц одно описание |
376
+ | `canonical.missing` | error | нет канонической ссылки |
377
+ | `jsonld.missing` | warn | на странице нет JSON-LD |
378
+ | `jsonld.invalid` | error | блок JSON-LD не разбирается |
379
+ | `jsonld.mismatch` | error | граф и `og:title` называют страницу по-разному |
380
+ | `image.missing` | warn | нет `og:image` |
381
+ | `image.broken` | error | `og:image` с того же сайта отсутствует в сборке |
382
+
383
+ Страницы-перенаправления, страницы с `noindex` и страницы, чей канонический адрес ведёт в другое место, не проверяются. Длина считается в знаках, а не в байтах. В журнале сборки показаны первые 15 страниц по каждому правилу; предел меняет `audit.limit`, а функция `audit()` возвращает все находки.
384
+
385
+ ```js
386
+ starlightSeo({
387
+ audit: {
388
+ failOn: 'error',
389
+ exclude: ['/go/**'],
390
+ rules: { 'title.short': 'error', 'image.missing': 'off' },
391
+ },
392
+ })
393
+ ```
394
+
395
+ `failOn` принимает `'error'` (по умолчанию), `'warn'` или `'off'`. `audit: false` отключает проверку. `audit.exclude` сравнивается с путями в собранном сайте, включая префикс языка.
396
+
397
+ Та же проверка доступна как функция:
398
+
399
+ ```js
400
+ import { audit, normalize } from 'starlight-seo'
401
+
402
+ const result = await audit('dist', normalize({}, { site: 'https://docs.example.com' }))
403
+ ```
404
+
405
+ Чистые функции, на которых построен обработчик, экспортируются из `starlight-seo/core` (`normalize`, `resolvePage`, `graphOf`, `applyHead`, `serialize`). Сайт может проверять свою настройку модульными тестами, без сборки.
406
+
407
+ ## Настройки
408
+
409
+ | Настройка | По умолчанию | Смысл |
410
+ |---|---|---|
411
+ | `site.name` | `title` из Starlight | название сайта в заголовках и JSON-LD |
412
+ | `site.alternateName`, `site.description`, `site.about` | - | поля `WebSite`; `about` также служит темой каждой статьи по умолчанию |
413
+ | `publisher` | сам сайт | `{ type, id, name, url, logo, sameAs }` |
414
+ | `title`, `description` | см. выше | пределы длины и шаблоны заголовков |
415
+ | `types`, `defaultType` | `[]`, `'TechArticle'` | типы страниц по путям |
416
+ | `image` | нет | картинка для соцсетей по умолчанию или шаблон |
417
+ | `robots` | директивы сниппета | содержимое мета-тега `robots` или `false` |
418
+ | `breadcrumbs` | `{ groups: 'link' }` | обработка групп и название первой крошки |
419
+ | `fallback` | `'canonical'` | канонический адрес непереведённых страниц |
420
+ | `exclude` | `['/404/', '/404.html']` | пути, которые плагин не трогает: ни заголовка, ни картинки, ни JSON-LD. Страница 404 сохраняет то, что даёт ей сайт |
421
+ | `extend` | нет | модуль с функциями `page` и `graph` |
422
+ | `audit` | включена, `failOn: 'error'` | проверка при сборке |
423
+
424
+ Любая текстовая настройка принимает строку или запись по языкам: `{ en: 'Docs', ru: 'Документация' }`.
425
+
426
+ ## Совместимость
427
+
428
+ | | Сделан для и проверен на | Наименьшая поддерживаемая | Откуда нижняя граница |
429
+ |---|---|---|---|
430
+ | Starlight | 0.42.5 | 0.32 | точка расширения `config:setup` и обработчики маршрута для плагинов появились в 0.32 |
431
+ | Astro | 7.3.5 | 5 | Starlight 0.32 требует Astro 5 |
432
+ | Node | 22, 24 | 20 | |
433
+
434
+ Версии между нижней границей и проверенным выпуском должны работать, но сборкой проверочного сайта подтверждена только проверенная пара. Если новый Starlight изменит устройство `route.head` или `route.sidebar`, это покажет `pnpm test:fixture`.
435
+
436
+ Страницы вне Starlight - обычные маршруты `src/pages/*.astro` без `<StarlightPage>` - обработчик не затрагивает, но проверка их читает. Настройка `base` в Astro, отличная от корня, пока не учитывается.
437
+
438
+ ## Откуда взяты приёмы
439
+
440
+ Плагин собирает в одно целое приём, который многие сайты на Starlight пишут вручную в собственном обработчике маршрута:
441
+
442
+ - крошки из дерева бокового меню и JSON-LD, добавленный в `route.head` - [документация Nx](https://github.com/nrwl/nx/blob/master/astro-docs/src/plugins/schema.middleware.ts);
443
+ - канонический адрес, взятый из уже собранного `<head>`, достройка Open Graph и исправление `og:type` - [документация Arcjet](https://github.com/arcjet/arcjet-docs/blob/main/src/routeData.ts);
444
+ - типизированные структурированные данные для страниц блога - [starlight-blog](https://github.com/HiDeoo/starlight-blog);
445
+ - единый граф узлов, связанных через `@id` - [seo-graph](https://github.com/jdevalk/seo-graph);
446
+ - проверка готового HTML с остановкой сборки - [astro-seo-enforcer](https://github.com/SlashGordon/astro-seo-enforcer).
447
+
448
+ ## Разработка
449
+
450
+ ```sh
451
+ pnpm install
452
+ pnpm test
453
+ pnpm test:fixture
454
+ ```
455
+
456
+ `pnpm test` запускает модульные тесты. `pnpm test:fixture` собирает настоящий сайт на Starlight из `tests/fixture` с плагином и проверяет готовый HTML.
457
+
458
+ Изменения перечислены в [CHANGELOG.md](./CHANGELOG.md). Ошибки и предложения - в [issues](https://github.com/kaktaknet/starlight-seo/issues).
459
+
460
+ ## Лицензия
461
+
462
+ [MIT](./LICENSE) © [kaktak.net](https://kaktak.net/)
package/core.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { SeoNode, SeoPage, StarlightSeoOptions } from './index.js'
2
+
3
+ export interface PageInput {
4
+ pathname: string
5
+ path: string
6
+ lang: string
7
+ locale?: string
8
+ homeHref: string
9
+ isHome: boolean
10
+ canonical: string
11
+ label: string
12
+ description?: string
13
+ template?: string
14
+ lastUpdated?: Date
15
+ siteTitle: string
16
+ sidebar: unknown[]
17
+ seo?: Record<string, unknown>
18
+ }
19
+
20
+ export interface HeadEntry {
21
+ tag: string
22
+ attrs?: Record<string, string | boolean | undefined>
23
+ content?: string
24
+ }
25
+
26
+ export type ResolvedOptions = Record<string, any>
27
+
28
+ export function normalize(options: StarlightSeoOptions, context: { site?: string | URL; delimiter?: string; defaultPrefix?: string }): ResolvedOptions
29
+ export function resolvePage(options: ResolvedOptions, input: PageInput): SeoPage
30
+ export function retitle(options: ResolvedOptions, page: SeoPage, title: string): Pick<SeoPage, 'title' | 'headTitle' | 'titleSource'>
31
+ export function applyHead(head: HeadEntry[], page: SeoPage, options: ResolvedOptions): void
32
+ export function graphOf(page: SeoPage, options: ResolvedOptions): SeoNode[]
33
+ export function pushGraph(head: HeadEntry[], nodes: SeoNode[]): void
34
+ export function wrap(nodes: SeoNode[]): { '@context': string; '@graph': SeoNode[] }
35
+ export function serialize(value: unknown): string
package/core.js ADDED
@@ -0,0 +1,5 @@
1
+ export { normalize } from './lib/options.js'
2
+ export { resolvePage, retitle } from './lib/page.js'
3
+ export { applyHead, graphOf, pushGraph } from './lib/head.js'
4
+ export { wrap } from './lib/graph.js'
5
+ export { serialize } from './lib/text.js'
package/index.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { StarlightPlugin } from '@astrojs/starlight/types'
2
+
3
+ export type Localized<T> = T | Record<string, T>
4
+
5
+ export interface ThingInput {
6
+ type?: string
7
+ name: string
8
+ description?: string
9
+ url?: string
10
+ sameAs?: string | string[]
11
+ }
12
+
13
+ export type AuditLevel = 'error' | 'warn' | 'off'
14
+
15
+ export type AuditRule =
16
+ | 'title.missing'
17
+ | 'title.short'
18
+ | 'title.long'
19
+ | 'title.duplicate'
20
+ | 'description.missing'
21
+ | 'description.short'
22
+ | 'description.long'
23
+ | 'description.duplicate'
24
+ | 'canonical.missing'
25
+ | 'jsonld.missing'
26
+ | 'jsonld.invalid'
27
+ | 'jsonld.mismatch'
28
+ | 'image.missing'
29
+ | 'image.broken'
30
+
31
+ export interface StarlightSeoOptions {
32
+ site?: {
33
+ name?: Localized<string>
34
+ alternateName?: Localized<string>
35
+ description?: Localized<string>
36
+ about?: Localized<string | ThingInput | Array<string | ThingInput>>
37
+ }
38
+ publisher?: {
39
+ type?: 'Organization' | 'Person'
40
+ id?: string
41
+ name?: string
42
+ url?: string
43
+ logo?: string
44
+ sameAs?: string[]
45
+ }
46
+ title?: {
47
+ min?: number
48
+ max?: number
49
+ brand?: 'auto' | 'always' | 'never'
50
+ delimiter?: string
51
+ site?: Localized<string>
52
+ templates?: Array<{ match: string | string[]; template: Localized<string> }>
53
+ }
54
+ description?: { min?: number; max?: number }
55
+ types?: Array<{ match: string | string[]; type: string; section?: string }>
56
+ defaultType?: string
57
+ image?: string | { src: Localized<string>; width?: number; height?: number; alt?: Localized<string> }
58
+ robots?: string | false
59
+ breadcrumbs?: { groups?: 'link' | 'plain' | 'skip'; home?: Localized<string> }
60
+ fallback?: 'canonical' | 'keep'
61
+ exclude?: string[]
62
+ extend?: string
63
+ audit?: false | { failOn?: 'error' | 'warn' | 'off'; limit?: number; exclude?: string[]; rules?: Partial<Record<AuditRule, AuditLevel>> }
64
+ }
65
+
66
+ export interface SeoCrumb {
67
+ label: string
68
+ href?: string
69
+ }
70
+
71
+ export interface SeoPage {
72
+ url: string
73
+ pathname: string
74
+ path: string
75
+ language: string
76
+ locale: string | undefined
77
+ isHome: boolean
78
+ label: string
79
+ title: string
80
+ headTitle: string
81
+ titleSource: 'frontmatter' | 'template' | 'label' | 'hook'
82
+ description: string | undefined
83
+ type: string
84
+ article: boolean
85
+ image: { url: string; width?: number; height?: number; alt?: string } | null
86
+ noindex: boolean
87
+ published: Date | string | undefined
88
+ modified: Date | string | undefined
89
+ section: string | undefined
90
+ keywords: string[] | undefined
91
+ about: unknown
92
+ breadcrumbs: SeoCrumb[]
93
+ site: { origin: string; name: string; alternateName?: string; description?: string; about?: unknown }
94
+ }
95
+
96
+ export type SeoNode = Record<string, unknown>
97
+
98
+ export type SeoPageHook = (page: SeoPage, context: import('astro').APIContext) => Partial<SeoPage> | void | Promise<Partial<SeoPage> | void>
99
+
100
+ export type SeoGraphHook = (nodes: SeoNode[], page: SeoPage, context: import('astro').APIContext) => SeoNode[] | void | Promise<SeoNode[] | void>
101
+
102
+ export default function starlightSeo(options?: StarlightSeoOptions): StarlightPlugin
103
+
104
+ export { audit } from './lib/audit.js'
105
+ export { normalize } from './core.js'