Skip to content

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.

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.json endpoint 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.

  1. Install the package:

    Terminal window
    npm install starlight-pydocs
  2. 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.

  3. 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 in integrations.

    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'],
    }),
    ],
    });
  4. Start the dev server. mypkg is documented at /api/mypkg/, one page per module, and each package also gets symbols.json, objects.inv and llms.txt alongside 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.

  • 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.

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:

src/pages/index.astro
---
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.

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:

astro.config.mjs
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:

src/layouts/ApiLayout.astro
---
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.

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:

astro.config.mjs
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.

<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:

src/pages/index.astro
---
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.

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.

src/content.config.ts
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:

src/pages/symbols.astro
---
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.

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.