Vanilla Astro
starlight-pydocs/astro is an ordinary Astro integration. It injects the same generated pages,
renders the same components and serves the same symbols.json, objects.inv and llms.txt as the
Starlight plugin. It cannot give you Starlight’s shell.
What changes without Starlight
Section titled “What changes without Starlight”The generated pages, every component, the search index, the Sphinx inventory and llms.txt all work
unchanged. Two things do not:
- No full-text search. Pagefind belongs to Starlight, so nothing indexes the prose of the generated pages. The symbol search box does not depend on Starlight. It still appears automatically on each package’s root page, but it matches object names, not prose.
- No sidebar and no prev/next links. Both come from Starlight’s route data, and a plain Astro
site has no equivalent. Build your own navigation from the
symbols.jsonendpoint or the Content Layer loader: every object carries the page slug it is documented on and its heading anchor.
@astrojs/starlight is an optional peer dependency. Nothing in this integration’s module graph
imports it, so you never have to install it.
Set it up
Section titled “Set it up”-
Install the package:
Terminal window npm install starlight-pydocs -
Make sure that Griffe is available in your system’s Python, or that uv is installed.
If you prefer, run Griffe separately and point the plugin at the JSON file it writes. See Without Python for more information.
-
Add the integration. The import is
starlight-pydocs/astro. The package’s default export is a Starlight plugin, not an Astro integration, so it does not belong inintegrations.astro.config.mjs import { defineConfig } from 'astro/config';import pydocs from 'starlight-pydocs/astro';export default defineConfig({integrations: [pydocs({packages: [{ name: 'mypkg', search: ['../src'] }],inventories: ['python'],}),],}); -
Start the dev server.
mypkgis documented at/api/mypkg/, one page per module, and each package also getssymbols.json,objects.invandllms.txtalongside its pages.
Every option is the same as the Starlight plugin’s, listed in
Configuration. This integration adds one option and
disables another. layout names a component of your own to wrap the generated pages.
injectStyles has no effect here, because a plain Astro project has no customCss for the
integration to append to.
Where the files go
Section titled “Where the files go”- astro.config.mjs the integration, and your markdown processor
Directorysrc/
Directorylayouts/
- ApiLayout.astro optional, replaces the built-in page layout
Directorypages/
- index.astro your own pages, where Autodoc goes
- content.config.ts optional, for the Content Layer loader
- middleware.ts optional, translates the generated pages
- package.json
Nothing under src/pages/ corresponds to the generated pages. The integration injects a catch-all
route, so /api/mypkg/… exists without a file of yours behind it.
The stylesheet
Section titled “The stylesheet”Import starlight-pydocs/styles once from any layout or page that renders the components. The
built-in layout imports it already, so this only matters for files you write yourself:
---import 'starlight-pydocs/styles';---The stylesheet works without Starlight. Its --pyd-* tokens fall back to static values when
Starlight’s --sl-* tokens are absent. It carries its own dark palette for both
prefers-color-scheme and an explicit data-theme="dark" on <html>. See
Theming.
The built-in layout
Section titled “The built-in layout”Generated pages render through a minimal built-in layout: a document, a content column, a table of contents built from the same headings the model produced, and the stylesheet. This layout lets you read the docs. It is not a full site design.
Pass layout to use your own component instead, as a path relative to the project root or a package
specifier:
pydocs({ packages: [{ name: 'mypkg', search: ['../src'] }], layout: './src/layouts/ApiLayout.astro',});It receives three props and renders the page body in its default slot:
| Prop | Type | Value |
|---|---|---|
title |
string |
The module’s dotted path, e.g. mypkg.report |
headings |
PageHeading[] |
{ depth, slug, text } per documented member |
description |
string |
The module’s one-line docstring summary |
Your layout replaces the built-in one entirely, so it has to import the stylesheet itself:
---import SiteHeader from '../components/SiteHeader.astro';import 'starlight-pydocs/styles';import '../styles/site.css';
interface Props { title: string; headings: { depth: number; slug: string; text: string }[]; description?: string | undefined;}
const { title, headings, description } = Astro.props;---
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <title>{title} — mypkg</title> {description ? <meta name="description" content={description} /> : null} </head> <body> <SiteHeader /> <main class="pyd-content"> <h1 class="pyd-page-title">{title}</h1> <slot /> </main> <nav aria-label="On this page"> <ul> {headings.map((heading) => ( <li data-depth={heading.depth}> <a href={`#${heading.slug}`}>{heading.text}</a> </li> ))} </ul> </nav> </body></html>slug is the dotted object path. It is also the heading’s id, so a table of contents is just a
list of #slug links. depth is 2 for a module’s own members and 3 for the members of a class.
Any markdown processor
Section titled “Any markdown processor”Docstring prose renders through whatever markdown.processor your project resolves to, and the
package depends on neither engine. Astro 7.1 and later default to Sätteri. A project pinned to the
unified pipeline, for mermaid or a remark plugin, works just as well:
import { unified } from '@astrojs/markdown-remark';import { defineConfig } from 'astro/config';import pydocs from 'starlight-pydocs/astro';
export default defineConfig({ markdown: { processor: unified(), shikiConfig: { themes: { light: 'github-light', dark: 'github-dark' }, defaultColor: false }, }, integrations: [pydocs({ packages: [{ name: 'mypkg', search: ['../src'] }] })],});Whatever the engine, docstring code fences are highlighted the way the rest of the site’s Markdown
is. On Astro 7.0.x, where markdown.processor does not exist yet, install
@astrojs/markdown-remark and it is used automatically.
Components in your own pages
Section titled “Components in your own pages”<Autodoc> documents one object wherever you put it. <SymbolSearch> is the search box the
generated root pages use. Both work in a hand-written page:
---import { Autodoc, SymbolSearch } from 'starlight-pydocs/components';import 'starlight-pydocs/styles';---
<SymbolSearch package="mypkg" />
<Autodoc name="mypkg.Report" /><Autodoc name="mypkg.build" headingLevel={3} labels={{ parameters: 'Args', returns: 'Yields' }} />There is no Starlight translation table for the components to read. That is why every label is a
prop. Each label resolves from labels first, then from a translation function on Astro.locals if
you set one, then from the bundled English default. The route renders the generated pages, not you,
so you translate those pages by setting Astro.locals.t in middleware. See
Internationalisation.
The Content Layer loader
Section titled “The Content Layer loader”starlight-pydocs/loader exposes the same model as data, one collection entry per documented
object. Use it to list, filter or tabulate the surface instead of rendering it. It runs the same
extraction, so a site can use both the loader and the integration and get matching results.
import { defineCollection } from 'astro:content';import { pydocsLoader } from 'starlight-pydocs/loader';
export const collections = { api: defineCollection({ loader: pydocsLoader({ name: 'mypkg', search: ['../src'] }), }),};pydocsLoader takes the per-package options plus runner, cacheDir and projectRoot. This lets
a collection read a pre-generated dump (source: { file: './dumps/mypkg.json' }) while the
integration extracts, or the other way round.
Each entry’s id is the object’s dotted path, and its data is typed:
---import { getCollection } from 'astro:content';import 'starlight-pydocs/styles';
const entries = (await getCollection('api')).sort((left, right) => left.data.path.localeCompare(right.data.path));const deprecated = entries.filter((entry) => entry.data.deprecated);---
<table class="pyd-summary-table"> <thead> <tr><th scope="col">Path</th><th scope="col">Kind</th><th scope="col">Signature</th><th scope="col">Summary</th></tr> </thead> <tbody> { entries.map((entry) => ( <tr data-symbol-path={entry.data.path}> <th scope="row"> <a href={`/${entry.data.page}/#${entry.data.anchor}`}> <code>{entry.data.path}</code> </a> </th> <td>{entry.data.kind}</td> <td><code>{entry.data.signature}</code></td> <td>{entry.data.brief}</td> </tr> )) } </tbody></table>
<p>{deprecated.length} deprecated objects.</p>| Field | Meaning |
|---|---|
path, canonicalPath |
Documented path (also the anchor) and the path griffe defined it at |
name, kind, labels |
Short name, object kind, griffe’s labels |
brief, docstring |
First docstring line as plain text, and the whole docstring |
signature |
Plain-text signature; empty for modules |
page, anchor |
Page slug the object is documented on, and its heading anchor |
deprecated |
true when anything marks it deprecated |
addedIn |
Release the object first appeared in; absent unless versions.refs is set |
page is the full page slug (api/mypkg/report), so a link is that slug plus the anchor, adjusted
for your site’s base if it has one.
A working example
Section titled “A working example”examples/vanilla in the repository is a complete site built this way: the integration on the
unified pipeline, a hand-written page with <Autodoc> and <SymbolSearch>, and a loader page. It
is one of the fixtures the end-to-end tests run against, so everything on this page is exercised
in CI.