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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-10-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `audit.limit`: how many pages per rule the build log shows (default 15).
|
|
10
|
+
- A `title` returned by the `page` hook now gets the site name by the same rule as any other title, so `<title>` and `og:title` stay consistent.
|
|
11
|
+
- Documentation: stable node identifiers, the hook contract, middleware order, how to keep existing titles and site-specific JSON-LD during an install.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- The crumb of the current page carries its sidebar label instead of the page title.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Nested sidebar groups that lead to the same page no longer produce two consecutive crumbs with one URL.
|
|
20
|
+
- The `image.broken` audit rule resolves the image path inside the build output only; a percent-encoded path can no longer point outside it, and a malformed one no longer throws.
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-10-05
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Search titles separate from the sidebar label: `seo.title` in frontmatter, per-section `title.templates`, site name appended only when it fits.
|
|
27
|
+
- One JSON-LD `@graph` per page: `WebSite`, publisher, `WebPage`, article node, `BreadcrumbList`, `ImageObject`, linked by `@id`.
|
|
28
|
+
- Breadcrumbs derived from the Starlight sidebar.
|
|
29
|
+
- Open Graph completion: `og:image` with size and alternative text, `og:type`, article dates, `twitter:image`.
|
|
30
|
+
- `robots` meta with snippet directives, and `noindex` per page.
|
|
31
|
+
- Canonical URL of untranslated fallback pages pointed at the source page.
|
|
32
|
+
- `extend` module with `page` and `graph` hooks for site-specific data.
|
|
33
|
+
- Build audit with 14 rules that fails the build on errors.
|
|
34
|
+
- Per-language values for every text option.
|
|
35
|
+
|
|
36
|
+
[0.2.0]: https://github.com/kaktaknet/starlight-seo/releases/tag/v0.2.0
|
|
37
|
+
[0.1.0]: https://github.com/kaktaknet/starlight-seo/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kaktak.net
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# starlight-seo
|
|
4
|
+
|
|
5
|
+
**Search-ready titles, connected JSON-LD, complete Open Graph tags and a build-time SEO audit for [Starlight](https://starlight.astro.build/) documentation sites.**
|
|
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.ru.md)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## At a glance
|
|
22
|
+
|
|
23
|
+
| | |
|
|
24
|
+
|---|---|
|
|
25
|
+
| **What** | A Starlight plugin. One line in `astro.config`, one line in `content.config`. |
|
|
26
|
+
| **Problem** | Starlight uses one `title` for the sidebar, the `<h1>`, the `<title>` tag and `og:title`. A good sidebar label ("Git", "Docker", "Install") is a poor search result. |
|
|
27
|
+
| **Solution** | The sidebar and heading keep the short label. Search engines and social cards get a full title from `seo.title` or a per-section template. |
|
|
28
|
+
| **Also** | One JSON-LD `@graph` per page, breadcrumbs taken from the sidebar, Open Graph completion, and an audit that fails the build on weak metadata. |
|
|
29
|
+
| **How** | Starlight route middleware. No component overrides, no runtime dependencies, no build step. |
|
|
30
|
+
| **Built for** | Starlight **0.42** on Astro **7** - developed and tested on 0.42.5 and 7.3.5. |
|
|
31
|
+
| **Works with** | Starlight ≥ 0.32, Astro ≥ 5, Node ≥ 20. `site` must be set in the Astro config. |
|
|
32
|
+
|
|
33
|
+
```diff
|
|
34
|
+
- <title>Git | MCP Doc</title>
|
|
35
|
+
+ <title>Git MCP server: tools, setup and known vulnerabilities | MCP Doc</title>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The sidebar still says **Git**. The `<h1>` still says **Git**.
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary><b>For AI agents: the whole repository in fourteen lines</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
|
+
Working instructions for agents live in [AGENTS.md](./AGENTS.md).
|
|
61
|
+
|
|
62
|
+
</details>
|
|
63
|
+
|
|
64
|
+
## Contents
|
|
65
|
+
|
|
66
|
+
- [Install](#install)
|
|
67
|
+
- [By hand](#by-hand)
|
|
68
|
+
- [With an AI agent](#with-an-ai-agent)
|
|
69
|
+
- [Check that it works](#check-that-it-works)
|
|
70
|
+
- [Titles](#titles)
|
|
71
|
+
- [Frontmatter](#frontmatter)
|
|
72
|
+
- [Structured data](#structured-data)
|
|
73
|
+
- [Open Graph and robots](#open-graph-and-robots)
|
|
74
|
+
- [Untranslated pages](#untranslated-pages)
|
|
75
|
+
- [Build audit](#build-audit)
|
|
76
|
+
- [Options](#options)
|
|
77
|
+
- [Compatibility](#compatibility)
|
|
78
|
+
- [Prior art](#prior-art)
|
|
79
|
+
- [Development](#development)
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
The package is installed from GitHub. Pin a tag, so that a build stays reproducible.
|
|
84
|
+
|
|
85
|
+
### By hand
|
|
86
|
+
|
|
87
|
+
**1. Add the package.**
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pnpm add github:kaktaknet/starlight-seo#v0.2.0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
<details>
|
|
94
|
+
<summary>npm and 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. Register the plugin.** `site` is required: canonical URLs and JSON-LD identifiers need an absolute origin.
|
|
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. Add the frontmatter fields to the docs collection.**
|
|
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
|
+
If the collection already extends the schema, merge the two: `seoSchema().extend({ ...yourFields })`.
|
|
142
|
+
|
|
143
|
+
**4. Put a 1200 × 630 image at `public/og.png`**, or drop the `image` option and set the audit rule `'image.missing': 'off'`.
|
|
144
|
+
|
|
145
|
+
**5. Build.** Use the project's own build script if it has one. The audit prints what to improve.
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
pnpm astro build
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Every page now has a JSON-LD graph, breadcrumbs and a social image. Titles improve as you add `seo.title` to pages or `title.templates` to the options.
|
|
152
|
+
|
|
153
|
+
What changes in the output right after the install: one JSON-LD graph with the identifiers listed under [Structured data](#structured-data), a `robots` meta tag with snippet directives, `og:type` set to `website` on non-article pages, and breadcrumbs built from the sidebar. Tests that assert on the old metadata shape need the new identifiers.
|
|
154
|
+
|
|
155
|
+
> [!IMPORTANT]
|
|
156
|
+
> If the site already writes its own JSON-LD, `og:image` or `<title>` in a `Head` override or in route middleware, remove that code. Two sources produce duplicate tags, and the audit rule `jsonld.mismatch` will report the disagreement.
|
|
157
|
+
|
|
158
|
+
### With an AI agent
|
|
159
|
+
|
|
160
|
+
Give your coding agent this prompt from the root of the Starlight project:
|
|
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) holds the same steps as above in a form an agent can execute and verify: what to detect first, which files to edit, what to remove, and the commands that prove the result. [CLAUDE.md](./CLAUDE.md) points Claude Code at the same file.
|
|
171
|
+
|
|
172
|
+
### Check that it works
|
|
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
|
+
Expected: the build ends with `[starlight-seo] audited N page(s): 0 error(s), ...`, the title is printed, and the count is `1`.
|
|
181
|
+
|
|
182
|
+
## Titles
|
|
183
|
+
|
|
184
|
+
The title is resolved in this order:
|
|
185
|
+
|
|
186
|
+
1. `seo.title` in the page frontmatter.
|
|
187
|
+
2. The first matching entry of `title.templates`.
|
|
188
|
+
3. The page `title`.
|
|
189
|
+
|
|
190
|
+
```md
|
|
191
|
+
---
|
|
192
|
+
title: Git
|
|
193
|
+
description: The reference Git server - twelve tools, the launch command and four fixed vulnerabilities.
|
|
194
|
+
seo:
|
|
195
|
+
title: 'Git MCP server: tools, setup and known vulnerabilities'
|
|
196
|
+
---
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
starlightSeo({
|
|
201
|
+
title: {
|
|
202
|
+
templates: [
|
|
203
|
+
{ match: '/sdk/*/', template: '{title} for MCP: install, versions, examples' },
|
|
204
|
+
{ match: '/servers/*/', template: { en: '{title} MCP server', ru: 'MCP-сервер {title}' } },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`{title}` is the page title and `{site}` is the site name. Patterns are matched against the path without the locale prefix: `*` matches one segment, `**` matches any depth.
|
|
211
|
+
|
|
212
|
+
A site that already keeps its titles in one place does not need frontmatter. A template without `{title}` and with an exact path is a per-page title, and the `page` hook may return `title` from any data source:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
title: { templates: [{ match: '/faq/', template: 'Questions and answers about the Example API' }] }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The site name is appended (`Title | Site`) only while the result fits `title.max`. A title that already mentions the site name is left alone. `og:title` and JSON-LD always carry the title without the site name.
|
|
219
|
+
|
|
220
|
+
A long site name and `brand: 'always'` push most titles over `title.max`. Either raise `title.max`, or give titles a shorter suffix with `title.site`, or keep `'auto'` and let long titles go without the suffix.
|
|
221
|
+
|
|
222
|
+
| Option | Default | Meaning |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `title.max` | `60` | longest full title; also the limit for appending the site name |
|
|
225
|
+
| `title.min` | `30` | shortest acceptable full title, used by the audit |
|
|
226
|
+
| `title.brand` | `'auto'` | `'auto'` appends the site name when it fits, `'always'`, `'never'` |
|
|
227
|
+
| `title.delimiter` | Starlight's `titleDelimiter` | separator before the site name |
|
|
228
|
+
| `title.site` | the site name | text appended to titles, when it should differ from the `WebSite` name |
|
|
229
|
+
| `title.templates` | `[]` | `{ match, template }` entries, first match wins |
|
|
230
|
+
|
|
231
|
+
## Frontmatter
|
|
232
|
+
|
|
233
|
+
Every field is optional and lives under `seo`.
|
|
234
|
+
|
|
235
|
+
| Field | Meaning |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `title` | search title; the sidebar and `<h1>` keep `title` |
|
|
238
|
+
| `description` | overrides `description` for meta tags and JSON-LD |
|
|
239
|
+
| `type` | schema.org type of the page, for example `FAQPage` or `BlogPosting` |
|
|
240
|
+
| `image`, `imageAlt` | social image for this page |
|
|
241
|
+
| `noindex` | emits `noindex, follow` and takes the page out of the audit |
|
|
242
|
+
| `published`, `modified` | dates; `modified` defaults to Starlight's `lastUpdated` |
|
|
243
|
+
| `section`, `keywords` | `articleSection` and `keywords` of an article |
|
|
244
|
+
| `about` | what the page is about: a name or `{ name, sameAs, url, type }`, one or many |
|
|
245
|
+
|
|
246
|
+
Pages rendered with `<StarlightPage>` take the same fields:
|
|
247
|
+
|
|
248
|
+
```astro
|
|
249
|
+
<StarlightPage frontmatter={{ title, description, seo: { type: 'BlogPosting', published } }}>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Structured data
|
|
253
|
+
|
|
254
|
+
Each page gets one `@graph` whose nodes reference each other by `@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` and its publisher (`Organization` or `Person`, with logo and `sameAs`);
|
|
268
|
+
- `WebPage` with the breadcrumb, the primary image and the dates;
|
|
269
|
+
- for article types, an article node (`TechArticle` by default) that points at the page through `mainEntityOfPage`;
|
|
270
|
+
- `BreadcrumbList` built from the sidebar;
|
|
271
|
+
- `ImageObject` when the page has a social image.
|
|
272
|
+
|
|
273
|
+
The page type is `seo.type`, then the first match in `types`, then `WebPage` for the home page, `CollectionPage` for `template: splash`, and `defaultType` (`TechArticle`) for everything else.
|
|
274
|
+
|
|
275
|
+
```js
|
|
276
|
+
starlightSeo({
|
|
277
|
+
site: {
|
|
278
|
+
description: 'A reference on the Example API',
|
|
279
|
+
about: { name: 'Example API', sameAs: 'https://www.wikidata.org/wiki/Q0' },
|
|
280
|
+
},
|
|
281
|
+
types: [{ match: '/blog/*/', type: 'BlogPosting', section: 'Blog' }],
|
|
282
|
+
breadcrumbs: { groups: 'link', home: 'Docs' },
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Node identifiers are stable and can be relied on in hooks and tests:
|
|
287
|
+
|
|
288
|
+
| Node | `@id` |
|
|
289
|
+
|---|---|
|
|
290
|
+
| `WebSite` | `<origin>/#website` |
|
|
291
|
+
| publisher | `publisher.id`, or `<publisher.url>#organization` (`#person` for a `Person`) |
|
|
292
|
+
| page | `<canonical>#webpage` |
|
|
293
|
+
| article | `<canonical>#article` |
|
|
294
|
+
| breadcrumbs | `<canonical>#breadcrumb` |
|
|
295
|
+
| image | `<canonical>#primaryimage` |
|
|
296
|
+
|
|
297
|
+
`inLanguage` is the Starlight `lang` of the page. `publisher` and `author` sit on the article node, not on `WebPage`. The crumb of the current page carries its sidebar label; nested groups that lead to the same page collapse into one crumb.
|
|
298
|
+
|
|
299
|
+
`breadcrumbs.groups` decides what a sidebar group becomes: `'link'` (default) points it at the first page of the group, `'plain'` keeps the name without a URL, `'skip'` leaves groups out.
|
|
300
|
+
|
|
301
|
+
### Adding your own nodes
|
|
302
|
+
|
|
303
|
+
Point `extend` at a module that exports `page`, `graph`, or both. The module runs on the server during rendering and may import your own data.
|
|
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` returns the fields to change before the head is written; a returned `title` gets the site name by the same rule as any other title. `graph` returns the final list of nodes and may add, change or remove any of them, the built-in ones included.
|
|
334
|
+
|
|
335
|
+
The plugin middleware runs after the site's own `routeMiddleware`, so it sees the head the site has already adjusted.
|
|
336
|
+
|
|
337
|
+
## Open Graph and robots
|
|
338
|
+
|
|
339
|
+
The plugin rewrites `og:title` and `og:description`, sets `og:type` to `website` for non-article pages, and adds `og:image` with its size and alternative text, `twitter:image`, and `article:published_time` / `article:modified_time`. Without an image `twitter:card` becomes `summary`.
|
|
340
|
+
|
|
341
|
+
`image` is a path, a URL, or a pattern with `{slug}`, `{lang}` and `{locale}`. `src` and `alt` accept a per-language record:
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
starlightSeo({ image: { src: '/og/{slug}.png', width: 1200, height: 630, alt: 'Example Docs' } })
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The plugin does not draw images. Any generator that writes files to that pattern works, for example [astro-og-canvas](https://github.com/delucis/astro-og-canvas) with a `src/pages/og/[...slug].ts` route. The audit reports an image that is missing from the build output.
|
|
348
|
+
|
|
349
|
+
A `robots` meta tag with `max-snippet:-1, max-image-preview:large, max-video-preview:-1` is added unless the page already has one. Set `robots: false` to turn it off, or pass your own string.
|
|
350
|
+
|
|
351
|
+
## Untranslated pages
|
|
352
|
+
|
|
353
|
+
When a locale has no translation, Starlight renders the default-language content under the locale URL. With the default `fallback: 'canonical'` such a page points its canonical URL at the source page, so search engines do not index the same text twice. Set `fallback: 'keep'` to leave the canonical URL as Starlight wrote it.
|
|
354
|
+
|
|
355
|
+
## Build audit
|
|
356
|
+
|
|
357
|
+
After `astro build` the plugin reads the generated HTML and reports problems. The build fails when a rule of level `error` fires.
|
|
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
|
+
| Rule | Default | Fires when |
|
|
367
|
+
|---|---|---|
|
|
368
|
+
| `title.missing` | error | there is no `<title>` |
|
|
369
|
+
| `title.short` | warn | the title is shorter than `title.min` |
|
|
370
|
+
| `title.long` | warn | the title is longer than `title.max` |
|
|
371
|
+
| `title.duplicate` | error | two pages share a title |
|
|
372
|
+
| `description.missing` | error | there is no meta description |
|
|
373
|
+
| `description.short` | warn | shorter than `description.min` (70) |
|
|
374
|
+
| `description.long` | warn | longer than `description.max` (160) |
|
|
375
|
+
| `description.duplicate` | error | two pages share a description |
|
|
376
|
+
| `canonical.missing` | error | there is no canonical link |
|
|
377
|
+
| `jsonld.missing` | warn | the page has no JSON-LD |
|
|
378
|
+
| `jsonld.invalid` | error | a JSON-LD block does not parse |
|
|
379
|
+
| `jsonld.mismatch` | error | the graph and `og:title` disagree about the page title |
|
|
380
|
+
| `image.missing` | warn | there is no `og:image` |
|
|
381
|
+
| `image.broken` | error | a same-origin `og:image` is not in the build output |
|
|
382
|
+
|
|
383
|
+
Redirect pages, `noindex` pages and pages whose canonical URL points elsewhere are not audited. Lengths are counted in characters, not bytes. The log shows the first 15 pages per rule; `audit.limit` changes that, and the `audit()` function returns every finding.
|
|
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` is `'error'` (default), `'warn'` or `'off'`. `audit: false` disables the audit. `audit.exclude` is matched against output paths, locale prefix included.
|
|
396
|
+
|
|
397
|
+
The same check is available as a function:
|
|
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
|
+
The pure functions behind the middleware are exported from `starlight-seo/core` (`normalize`, `resolvePage`, `graphOf`, `applyHead`, `serialize`), so a site can unit-test its own configuration without a build.
|
|
406
|
+
|
|
407
|
+
## Options
|
|
408
|
+
|
|
409
|
+
| Option | Default | Meaning |
|
|
410
|
+
|---|---|---|
|
|
411
|
+
| `site.name` | Starlight `title` | site name in titles and JSON-LD |
|
|
412
|
+
| `site.alternateName`, `site.description`, `site.about` | - | `WebSite` fields; `about` is also the default topic of every article |
|
|
413
|
+
| `publisher` | the site itself | `{ type, id, name, url, logo, sameAs }` |
|
|
414
|
+
| `title`, `description` | see above | length limits and title templates |
|
|
415
|
+
| `types`, `defaultType` | `[]`, `'TechArticle'` | page types by path |
|
|
416
|
+
| `image` | none | default social image or pattern |
|
|
417
|
+
| `robots` | snippet directives | `robots` meta content, or `false` |
|
|
418
|
+
| `breadcrumbs` | `{ groups: 'link' }` | group handling and the label of the first crumb |
|
|
419
|
+
| `fallback` | `'canonical'` | canonical URL of untranslated pages |
|
|
420
|
+
| `exclude` | `['/404/', '/404.html']` | paths the plugin leaves untouched: no title, no image, no JSON-LD. The 404 page keeps whatever the site gives it |
|
|
421
|
+
| `extend` | none | module with `page` and `graph` hooks |
|
|
422
|
+
| `audit` | on, `failOn: 'error'` | build audit |
|
|
423
|
+
|
|
424
|
+
Every text option accepts a string or a per-language record: `{ en: 'Docs', ru: 'Документация' }`.
|
|
425
|
+
|
|
426
|
+
## Compatibility
|
|
427
|
+
|
|
428
|
+
| | Built for and tested on | Lowest supported | Why that floor |
|
|
429
|
+
|---|---|---|---|
|
|
430
|
+
| Starlight | 0.42.5 | 0.32 | the `config:setup` hook and plugin route middleware appeared in 0.32 |
|
|
431
|
+
| Astro | 7.3.5 | 5 | Starlight 0.32 requires Astro 5 |
|
|
432
|
+
| Node | 22, 24 | 20 | |
|
|
433
|
+
|
|
434
|
+
Versions between the floor and the tested release are expected to work, but only the tested pair is exercised by the fixture build. If a newer Starlight changes the shape of `route.head` or `route.sidebar`, `pnpm test:fixture` is the test that shows it.
|
|
435
|
+
|
|
436
|
+
Pages rendered outside Starlight - plain `src/pages/*.astro` routes that do not use `<StarlightPage>` - are not touched by the middleware, but the audit still reads them. A non-root Astro `base` is not handled yet.
|
|
437
|
+
|
|
438
|
+
## Prior art
|
|
439
|
+
|
|
440
|
+
The plugin packages a technique that many Starlight sites implement by hand in their own route middleware:
|
|
441
|
+
|
|
442
|
+
- breadcrumbs from the sidebar tree and JSON-LD pushed into `route.head` - the [Nx docs](https://github.com/nrwl/nx/blob/master/astro-docs/src/plugins/schema.middleware.ts);
|
|
443
|
+
- canonical URL read from the head Starlight already built, Open Graph completion and the `og:type` fix - the [Arcjet docs](https://github.com/arcjet/arcjet-docs/blob/main/src/routeData.ts);
|
|
444
|
+
- typed structured data for blog pages - [starlight-blog](https://github.com/HiDeoo/starlight-blog);
|
|
445
|
+
- one graph of nodes linked by `@id` - [seo-graph](https://github.com/jdevalk/seo-graph);
|
|
446
|
+
- auditing the final HTML and failing the build - [astro-seo-enforcer](https://github.com/SlashGordon/astro-seo-enforcer).
|
|
447
|
+
|
|
448
|
+
## Development
|
|
449
|
+
|
|
450
|
+
```sh
|
|
451
|
+
pnpm install
|
|
452
|
+
pnpm test
|
|
453
|
+
pnpm test:fixture
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
`pnpm test` runs the unit tests. `pnpm test:fixture` builds a real Starlight site from `tests/fixture` with the plugin and checks the generated HTML.
|
|
457
|
+
|
|
458
|
+
Changes are listed in [CHANGELOG.md](./CHANGELOG.md). Bugs and ideas go to [issues](https://github.com/kaktaknet/starlight-seo/issues).
|
|
459
|
+
|
|
460
|
+
## License
|
|
461
|
+
|
|
462
|
+
[MIT](./LICENSE) © [kaktak.net](https://kaktak.net/)
|