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 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
+ [![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
+ [![Release](https://img.shields.io/github/v/tag/kaktaknet/starlight-seo?label=release&sort=semver)](https://github.com/kaktaknet/starlight-seo/tags)
9
+ [![License: MIT](https://img.shields.io/github/license/kaktaknet/starlight-seo)](./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
+ [![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./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/)