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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +462 -0
- package/README.ru.md +462 -0
- package/core.d.ts +35 -0
- package/core.js +5 -0
- package/index.d.ts +105 -0
- package/index.js +66 -0
- package/lib/audit.d.ts +17 -0
- package/lib/audit.js +164 -0
- package/lib/breadcrumbs.js +33 -0
- package/lib/graph.js +138 -0
- package/lib/head.js +72 -0
- package/lib/options.js +87 -0
- package/lib/page.js +75 -0
- package/lib/text.js +36 -0
- package/lib/title.js +14 -0
- package/middleware.js +53 -0
- package/package.json +76 -0
- package/schema.d.ts +4 -0
- package/schema.js +31 -0
- package/virtual.d.ts +9 -0
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
|
+
[](https://github.com/kaktaknet/starlight-seo/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/kaktaknet/starlight-seo/tags)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
[](https://starlight.astro.build/)
|
|
11
|
+
[](https://astro.build/)
|
|
12
|
+
[](https://nodejs.org/)
|
|
13
|
+
[](./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
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'
|