Skip to content

Versioned docs

Shipping 2.0 does not move everyone off 1.x. Users who stay on 1.x need the 1.x API reference to stay where it was. One plugin instance serves both: mypkg 2.x at /api/mypkg/ from your working tree, and mypkg 1.x at /1x/api/mypkg/ from a dump frozen when 1.4.3 shipped.

If you want to badge the objects of a single release with the version each one first appeared in, use Version annotations instead. The two features work together: the current entry can carry “Added in” badges while the archived entries stay as they were.

This takes one packages entry per release, each with its own base and its own pinned dump. A dump is a complete, frozen description of one release’s API surface. Reading a dump needs no Python and no checkout of that release.

astro.config.mjs
starlightPydocs({
packages: [
// The current release, extracted from the working tree.
{
name: 'mypkg',
search: ['../src'],
sourceLink: { host: 'github', repo: 'you/mypkg', ref: 'v2.1.0', root: '..' },
},
// The 1.x release, pinned to a dump published with it.
{
name: 'mypkg',
base: '1x/api/mypkg',
label: 'mypkg 1.x',
source: { url: 'https://github.com/you/mypkg/releases/download/v1.4.3/api.json' },
sourceLink: { host: 'github', repo: 'you/mypkg', ref: 'v1.4.3' },
},
],
});

name is the Python import name, which every version shares. base identifies an entry: bases must be distinct, and no base may contain another. label names the entry for humans: the sidebar group, the llms.txt heading and the published inventory. It defaults to name.

This documentation site is built that way: demopkg is documented at /api/demopkg/ from the fixture source, and again at /1x/api/demopkg/ from the checked-in dump.

Everything on this page uses one plugin instance with several packages entries. That is the only arrangement that works. Registering starlightPydocs() twice in one Starlight config does not work: both instances resolve the same virtual context module and inject the same catch-all route. As a result, the second instance’s pages do not exist.

An entry gets its own pages under its own base, its own docstring rendering and its own model. The files served beside those pages (symbols.json, objects.inv and llms.txt) are per entry too, unless symbolSearch, publishInventory or llmsTxt switches one off across the whole site.

Links stay inside the entry that produced them. A type annotation, a member link or a docstring cross-reference on a 1.x page resolves against the 1.x model. So /1x/api/mypkg/report/#mypkg.report.Report never links to the 2.x page of the same class. Resolution across entries skips entries with the same import name. This keeps the two documented versions separate.

An entry documenting a past release must not be extracted from the working tree, or it will describe whatever main looked like at build time. Pin it:

  • source: { url } for a dump your release workflow published as a release asset.
  • source: { file } for a dump committed into the docs site.

Set sourceLink.ref to the same release. Line numbers move between releases, so a source link built from main lands in the wrong place. Leave sourceLink.root out of a pinned entry, though: root rewrites the paths griffe recorded, and griffe recorded them wherever it made the dump, usually a CI runner. See Pre-generated dumps for how to produce and publish api.json.

Every package’s pages go into the shared pydocsSidebarGroup placeholder unless you say otherwise. Give the archived version a placeholder of its own from createPydocsSidebarGroup(), hand it to that entry, and put it where the version belongs:

astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlightPydocs, { createPydocsSidebarGroup, pydocsSidebarGroup } from 'starlight-pydocs';
const legacyApi = createPydocsSidebarGroup();
export default defineConfig({
integrations: [
starlight({
title: 'mypkg',
plugins: [
starlightPydocs({
packages: [
{ name: 'mypkg', search: ['../src'] },
{
name: 'mypkg',
base: '1x/api/mypkg',
label: 'mypkg 1.x',
source: { file: './dumps/mypkg-1.4.3.json' },
sidebar: { group: legacyApi },
},
],
}),
],
sidebar: [
{ label: 'API reference', items: [pydocsSidebarGroup] },
{ label: 'v1.x', collapsed: true, items: [legacyApi] },
],
}),
],
});

starlight-versions versions the content collection. It copies src/content/docs into a per-version directory. Generated API pages are injected routes, not files (why). So starlight-versions has no file to copy, and it does not snapshot the API reference.

Split the work. Let starlight-versions version the prose, and put each API entry’s group inside the matching version’s sidebar. Those version sidebars are ordinary Starlight sidebars, so a placeholder works there exactly as it does above:

astro.config.mjs
starlight({
plugins: [
starlightVersions({ versions: [{ slug: '1.x' }] }),
starlightPydocs({
packages: [
{ name: 'mypkg', search: ['../src'] },
{
name: 'mypkg',
base: '1x/api/mypkg',
base: '1.x/api/mypkg',
label: 'mypkg 1.x',
source: { file: './dumps/mypkg-1.4.3.json' },
sidebar: { group: legacyApi },
},
],
}),
],
sidebar: [
{ label: 'Guides', autogenerate: { directory: 'guides' } },
{ label: 'API reference', items: [pydocsSidebarGroup] },
{ label: 'v1.x', collapsed: true, items: [legacyApi] },
{ label: 'v1.x API', items: [legacyApi] },
],
}),

Rename the base to match the version slug starlight-versions uses. This puts both halves of a version under the same prefix: /1.x/ for the copied prose, /1.x/api/mypkg/ for the API reference. Do this before the site is public. A base is a URL, so changing it later breaks every link into the archived pages.

An import name identifies nothing once it is documented twice. So an <Autodoc> block that would otherwise resolve by name must say which entry it means, by base:

<Autodoc name="mypkg.Report" package="api/mypkg" />
<Autodoc name="mypkg.Report" package="1x/api/mypkg" />

Without it the build stops and lists the candidate bases. <SymbolSearch package="…" /> takes the same value. Both also accept a plain import name where only one entry documents that package.

Documenting versions in one build keeps every release at a stable URL and one deployment. The alternative is still available: one site build per version, each pinning its own dump and deployed under its own path prefix. It is the better fit when the prose differs so much between versions that you want whole separate sites.

astro.config.mjs
const version = process.env.DOCS_VERSION ?? 'v2.1.0';
const isLatest = process.env.DOCS_LATEST === 'true';
export default defineConfig({
site: 'https://docs.example.com',
base: isLatest ? '/' : `/${version}`,
integrations: [
starlight({
title: `mypkg ${version}`,
plugins: [
starlightPydocs({
packages: [
{
name: 'mypkg',
source: { url: `https://github.com/you/mypkg/releases/download/${version}/api.json` },
sourceLink: { host: 'github', repo: 'you/mypkg', ref: version },
},
],
}),
],
sidebar: [{ label: 'API reference', items: [pydocsSidebarGroup] }],
}),
],
});
Terminal window
DOCS_VERSION=v2.1.0 DOCS_LATEST=true pnpm build && mv dist public-site
DOCS_VERSION=v1.4.3 pnpm build && mv dist public-site/v1.4.3

Every href the plugin emits goes through Astro’s base, so the archived pages work under their prefix with no further configuration.