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.
One entry per version
Section titled “One entry per version”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.
Each entry is self-contained
Section titled “Each entry is self-contained”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.
Pin the archived dump
Section titled “Pin the archived dump”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.
Give each version its own sidebar group
Section titled “Give each version its own sidebar group”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:
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] }, ], }), ],});Alongside starlight-versions
Section titled “Alongside starlight-versions”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:
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.
Point <Autodoc> at a base
Section titled “Point <Autodoc> at a base”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.
One build per version instead
Section titled “One build per version instead”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.
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] }], }), ],});DOCS_VERSION=v2.1.0 DOCS_LATEST=true pnpm build && mv dist public-siteDOCS_VERSION=v1.4.3 pnpm build && mv dist public-site/v1.4.3Every href the plugin emits goes through Astro’s base, so the archived pages work under their
prefix with no further configuration.