@eqtylab/docs
An Astro documentation framework built on the Equality design system.
One package to build every EQTY docs site — component library, product docs, and API reference —
with folder-driven navigation and versioned deploys.
Quick start
pnpm add @eqtylab/docs @eqtylab/equality astro react react-dom
import docs from '@eqtylab/docs';
import { defineConfig } from 'astro/config';
const REPO = 'https://github.com/eqtylab/my-repo';
export default defineConfig({
site: 'https://docs.example.com',
base: process.env.DOCS_BASE ?? '/',
integrations: [
docs({
title: 'My Docs',
repository: { url: REPO },
header: {
links: [{ label: 'GitHub', href: REPO, icon: 'simple-icons:github', external: true }],
},
}),
],
});
import { docsCollections } from '@eqtylab/docs/loaders';
export const collections = docsCollections();
Then write MDX under src/content/docs/. That's the whole setup: the integration injects the
page routes, a Markdown twin per page, /llms.txt, and a 404. Search is built separately, at the
end of astro build; see Search needs a build.
Content is MDX-only. Component overrides apply exclusively to MDX, so a .md page would
render its tables, code fences and callouts differently from every other page. A .md file in
the content tree fails the build rather than going silently missing — renaming it is usually the
only change needed, since MDX is a superset of Markdown.
EQTY Lab brand files
Every site gets the EQTY Lab favicon, the header logo and a share image without configuring
anything. The files are served from the package under /_equality/, so a site copies nothing
into public/.
Each one can be replaced or turned off:
favicon | favicon: '/mine.svg' | — |
logo | logo: { src: '/mine.svg', alt: 'Acme' } | logo: false (the header shows title as text, then "Docs") |
ogImage | ogImage: '/share.png' or a URL | ogImage: false |
ogImage is the picture in a shared link's preview card, in Slack, LinkedIn and the like. Those
apps only accept a full URL, so set Astro's site; without it a path is left out.
Header and footer links
docs({
title: 'My Docs',
repository: { url: 'https://github.com/eqtylab/my-repo' },
license: { name: 'Apache 2.0', href: 'https://github.com/eqtylab/my-repo/blob/main/LICENSE' },
header: {
links: [
{ label: 'Blog', href: 'https://eqtylab.io/blog', icon: 'lucide:newspaper', external: true },
],
},
footer: {
links: [
{
prefix: 'Install it from',
label: 'npm',
href: 'https://www.npmjs.com/package/my-package',
icon: 'simple-icons:npm',
external: true,
},
],
},
});
Header links and footer rows share one shape: label, href, icon and external. A footer
row adds prefix, and its href is optional.
There are two footers. The article footer ends each page, under the prose: the previous and next
pages, then, when repository or feedback is set, up to two links side by side.
- "Edit this page" opens the page's own file on GitHub, on
repository.branch (main unless you say otherwise). The file's path is read from git, so the
site has to build from a checkout; outside one the build warns and leaves this link off. It is
also left off archived versions, whose files are no longer in the repo.
- "Report a problem with this page" opens
feedback when set, such as a support portal, and
otherwise a new issue in the repository. With feedback alone, it is the only link. feedback
must be a full URL; the build rejects a relative one.
The app footer runs across the bottom of every page, under the article and the table of contents
but not the sidebar. It holds footer.links, then "© EQTY Lab". The top-level
license adds the project's license to that line, as "© EQTY Lab · Apache 2.0". Give its
name, such as 'Apache 2.0', or { name, href } to link the name to the license text. Leave it
out for a project without one, such as an internal product.
prefix, label and href | "Install it from npm" with the label linked |
label and href | "npm" |
label only | Plain text, exactly as written, such as "Built with Astro" |
icon | The icon in front |
external: true opens a link in a new tab. A link starting with / gets the site's sub-folder
(Astro's base), so /latest/ works on a site served from /integrity-py/.
Icons
Both sets come with the package. A name that doesn't exist stops the build and says which.
Sidebar icons (icon in a page's frontmatter, and sidebar.extra) still take Lucide's own names,
like BookOpen, until the sidebar moves to these links.
The site card
The sidebar starts with a card naming the site. The name opens a menu of EQTY Lab's public docs
sites, and the version row under it opens the version menu. The header shows only the logo and
"Docs", however long the site's name is. There is nothing to configure.
| Public | Add it to eqtyDocsSites in src/sites.ts and release | The public sites, in list order | List it once they upgrade |
| Private | Nothing | Itself at the top, then the public sites | Never list it |
This package is public, so a private site's address never goes in the list.
A public site's row shows the description on its list entry. A private site's row shows its own
description from docs({ ... }). Either falls back to the site's address.
A site that keeps its own version list, rather than using Versions, passes it to
DocsPage as switcher. Its refresh names a URL serving the same shape, fetched on load, so a
frozen copy can offer versions released after it was built. Make it root-absolute or a full URL,
since a relative one resolves against each page; another origin needs CORS. A fetched list that
does not contain the current page's href is ignored, and so is any failure.
A landing page
Set template: landing on a page, usually the content root's index.mdx, and list its cards in
sections. It keeps the sidebar and version menu, has no outline, and shows the title, description
and cards in place of a body:
---
title: Integrity Python SDK
description: Track data provenance, asset lineage and computation integrity from Python.
template: landing
sections:
- heading: Get Started
cards:
- title: Install
description: From PyPI or source.
icon: Download
links:
- [Package Managers, /install/package-managers/]
- [Changelog, https://github.com/eqtylab/integrity-py/blob/main/CHANGELOG.md]
---
A card's icon is optional: a Lucide name, as in the sidebar, shown beside the title. A name
Lucide lacks fails the build. Each section picks its columns from its card count so rows fill: an
even count goes two across, a multiple of three goes three across once there is room and lies
sideways one per row until then, five or more go three across, and one lays out sideways with its links beside the
description. The page's description keeps a newline (a | block in YAML), so a two-sentence
description can break between sentences.
A / link is rewritten like a link in page text, so in an old version it stays in that version; a
full URL opens a new tab. The build stops when a landing page has no sections or has a body, and
when any other page has sections. The Markdown twin lists the sections as links. Search leaves
the page out, since it has no article.
A page of your own renders the same layout with Landing from
@eqtylab/docs/components/Landing.astro, typed by its LandingSection export. Put it in DocsPage
with showToc={false}, or the page gets an outline holding only its title.
Navigation comes from folders
Ordering lives in one optional _group.yaml per directory — there is deliberately no
sidebar.order frontmatter to hand-maintain.
label: Getting Started
order: [installation, usage, themes]
collapsed: false
- Children listed in
order come first, in that order.
- Everything unlisted appends alphabetically by sidebar label (
sort: filename or manual to change that).
- A section's overview always leads it, whether that is the folder's
index.mdx, a page named
overview, or one titled "Overview" - alphabetical order would otherwise bury it mid-list.
index.mdx in a folder becomes that folder's own page, listed first in the group as
Overview. A section header only expands - it is never a link - because not every
section has an index, and a header that navigates only sometimes teaches the wrong habit.
Rename that row with indexLabel on the group, or globally with sidebar.indexLabel.
The content root's index.mdx is the site landing page and stays out of the sidebar.
- Folders start closed, except those holding the current page. Set
collapsed: false on a folder
to start it open, or sidebar: { collapsed: false } to start every folder open. With JavaScript
off, every folder shows open.
- A typo in
order produces a build warning naming the file and the token rather than silently
reordering, and the schema is .strict() so an unknown key fails the build.
icon on a top-level folder (_group.yaml) or page (frontmatter) shows in front of its label.
Icons show on top-level rows only. Give every top-level folder and page one, or none: a mix
leaves the rows' text out of line.
badge marks a folder or page with a status, such as { text: Beta, variant: warning }. It
shows after the label at any depth, and screen readers read it with the label. deprecated: true
adds one for you.
- A link in a folder's
links can carry attrs, such as { data-track: status }. Only data-*,
aria-* and id reach the page; anything else is dropped.
Linking between pages
Write the link the way the file sits on disk and it resolves to that page's URL:
See [usage](./usage.mdx) and [the guides index](../guides/index.mdx#ordering).
./usage.mdx becomes /getting-started/usage/, with base, pathPrefix and the trailing slash
applied exactly as the sidebar applies them. A .md target finds the .mdx file of the same name,
so markdown synced in from a product repo needs no rewriting, and a folder's index.mdx resolves to
the folder. A target that names no page in the content directory is a build warning naming the
file and the href — it still rewrites, so the typo shows up in the log rather than only as a 404.
Only content files are rewritten, and only targets that land inside the content directory: a
relative link out of the collection is someone else's URL to own. Root-relative links are
base-prefixed instead — Astro does not do that for authored markdown — and extensionless relative
links are left to the browser.
Three conventions worth knowing
Styling is inline Tailwind, and one @source line is what makes it work. The chrome is styled
with utility classes written in the markup. That only compiles because runtime/styles/docs.css
names this package's own files in an explicit @source: Tailwind v4's automatic source detection
skips node_modules, which is exactly where this markup sits in a consumer's build. Drop that line
and every utility here produces nothing, with no error anywhere. pnpm run check:source enforces
that the glob still covers all shipped markup, and runs as part of build. Recipes used by more
than one component are @utility definitions in runtime/styles/utilities.css; a co-located
*.module.css is the escape hatch for rules that cannot live on an element, which today means
only GlobalSearch.module.css.
Overruling an Equality component takes !. packages/ui ships unlayered CSS, Tailwind
utilities live in @layer utilities, and an unlayered declaration beats a layered one whatever its
specificity. So where this markup has to overrule a value an Equality class already sets on the
same element — CodeBlock's max-height, CommandList's — the utility carries !. Properties
Equality leaves alone need nothing. Getting this wrong fails silently: the class is in the HTML and
the rule is in the bundle, it just loses.
Route-level MDX components override page-level ones. @astrojs/mdx builds the map as
{ Fragment, ...fileComponents, ...props.components }, so the route's <Content components={…} />
wins over a page's own export const components. The route map is therefore kept to element
overrides (a, pre); everything richer is an explicit import from @eqtylab/docs/components, so
per-page overrides stay possible.
Headings and spacing
An h2 is a title or a label, decided by what follows it. With text after it, an h2 is a 20px
title. With an h3 straight after it, it becomes a mono label that groups the h3s below (Variants,
Props). Text between them is fine; the h2 simply stays a title. Tailwind's docs follow the same
rule.
Space follows level, not size. A label is the smallest heading on the page, yet it gets the
largest gap, because it starts the biggest change of topic.
| The page header's rule and the content | 32px |
| Above any h2, title or label | 48px |
| A label and its first h3 | 24px |
| Above an h3 after text or a block | 32px |
| Above h4 to h6 | 24px |
| An h2 and its own text | 16px |
| An h3 to h6 and its own text | 12px |
| Below paragraphs, lists, code blocks, tables, tabs, alerts, rules | 16px |
| List items | 8px |
| Above the article footer, and from it to the site footer | 64px |
| The previous/next links and the edit and report links, each side | 32px |
| The site footer's padding | 24px |
A one-line site footer is as tall as the header. That holds only while --eq-docs-header-height
stays 4.3125rem; change the footer's padding with it.
The header gap stays under the h2 gap. The header introduces the content rather than
separating it, so the first heading must not read as a section break. The heading right after the
header has its top margin zeroed in runtime/styles/prose.css; without that, its h2 gap wins over
the header's own margin, which is how 0.12 shipped 80px there by accident.
A component that shows nothing goes at the end of the page. Something like
<ToastRoot client:only="react" /> renders no box, but it is still the element after the header,
so it takes the zeroed margin and the first heading keeps its 48px. CSS cannot tell it from an
island that does render, so put it at the end of the page.
Code blocks
Fenced code renders through Equality's CodeBlock, server-side, so docs match product surfaces with
no client React. The highlighter paints through the CSS Custom Highlight API rather than by
wrapping tokens in markup, so the server-rendered DOM is the final DOM, the palette resolves through
the normal cascade, and a static render gets correct light and dark colours for free.
Two small scripts do what would otherwise need hydration: runtime/scripts/eq-copy.ts for the copy
button, and runtime/scripts/eq-highlight.ts to start the highlight pass. Fence tags are passed
through as authored — the highlighter owns language resolution, including its alias table (ts,
jsx, sh, yml and the rest), and renders plain rather than guessing at a tag it does not know.
Set code: { highlighter: 'shiki' } to use Astro's Shiki instead: wider language coverage, line
highlighting and meta support, at the cost of a different look from the product.
Everything renders through Equality
The framework contributes layout, navigation and prose typography. Everything that renders
content is an Equality component, server-rendered with no client React, so docs and
product surfaces cannot drift:
| Code fences | CodeBlock |
| Markdown tables | TableContainer / TableHeader / TableBody / TableRow / TableHead / TableCell |
<Alert> | Alert, as an aside |
<Tabs> | Tabs, server-rendered |
| Links | base-aware a override |
Authors write Equality's own components, with no imports. The route-level component map
supplies named components as well as element overrides, so this works in any page with
nothing at the top of the file — there is no docs-specific component vocabulary to learn:
---
title: My page
---
<Alert variant="warning" title="Heads up">
Body content, with real block elements.
</Alert>
The only thing the framework adds is a default: as="aside". Alert's default div carries
role="alert", an assertive live region — right for a message responding to something the
user did, wrong for a standing note in a document. Importing Alert from
@eqtylab/equality explicitly opts out and gives you the raw component, because a
file-level import wins over the component map.
One detail worth knowing: tables need a column count. TableContainer is a CSS grid
whose parts all use subgrid, so it needs an explicit track list or the table collapses
to one column. Markdown has no syntax for that, so rehypeTableColumns derives the count
from the first row at build time.
Tabs are authored the same way, with no imports:
<Tabs syncKey="platform">
<TabItem label="Linux">Anything, including fences and tables.</TabItem>
<TabItem label="macOS">
Sets sharing a `syncKey` switch together, and the choice is remembered.
</TabItem>
</Tabs>
This is also why content is MDX-only — these overrides are the mechanism, and Astro's
plain-Markdown pipeline has no component substitution at all.
Bringing your own CSS and scripts
customCss and clientScripts attach a consumer's own assets to every docs page:
docs({
title: 'My Docs',
customCss: ['./src/styles/site.css'],
clientScripts: ['./src/scripts/glossary.ts'],
});
Both resolve project-relative paths against the project root, so they mean the same thing in
dev and in a build. clientScripts entries are bundled rather than served as-is, which is
what a script that rewrites rendered text needs: it has to import scheduleHighlight from
@eqtylab/equality and call it afterwards, or the code blocks it touched lose their
highlighting. (CodeBlock paints through the CSS Custom Highlight API over Ranges into those
very text nodes.) A plain <script src> in public/ cannot import anything.
Owning a page yourself
A file in your own src/pages wins. The integration sees the route is already claimed, logs that
it is yielding, and injects nothing there — so a landing page is yours to write, with the same
chrome every other page gets:
---
import Prose from '@eqtylab/docs/chrome/Prose.astro';
import DocsPage from '@eqtylab/docs/layouts/DocsPage.astro';
import { docsNav } from '@eqtylab/docs/lib/nav-data.ts';
// `nav` is required: the sidebar, the drawer and the 404's suggestions all read it.
const nav = await docsNav(Astro.url.pathname);
---
<DocsPage title="Home" nav={nav} toc={[]} showToc={false} splash>
<Prose title="Home">Anything at all.</Prose>
</DocsPage>
DocsPage shows "On this page" unless showToc is false, even with an empty toc, so a page of
your own without an outline passes showToc={false}, as above.
@eqtylab/docs/lib/* ships the rest of what the injected route uses, with the collection already
wired in: docsEntries and pathContext alongside docsNav in nav-data.ts, breadcrumbsFor /
prevNextFor / buildTocTree re-exported from there, and mdxComponents in mdx-components.ts
for rendering a collection entry through the same component map.
Plugins
A plugin is code that runs while Astro reads your config, so it can generate a whole section of
the site: an API reference from an OpenAPI document, a changelog from git, anything that is
pages plus a place in the sidebar. Pass instances to docs({ plugins }):
docs({
title: 'My Docs',
plugins: [openApiReference({ spec: './public/openapi.yaml' })],
});
A plugin is { name, setup(ctx) }. setup runs inside astro:config:setup, after the framework
has planned its own routes, and receives:
ctx.astro — the raw hook params: config, injectRoute, updateConfig, addWatchFile,
logger. Inject a route per generated page; its entrypoint can import
@eqtylab/docs/layouts/DocsPage.astro, @eqtylab/docs/chrome/Prose.astro and the helpers in
@eqtylab/docs/lib/*, so a generated page gets the same chrome as an authored one.
ctx.addNavGroup(group) — a NavNode appended to the top of the sidebar, after the
folder-derived groups and sidebar.extra. It is static: for current-page state, or to nest
the group inside an authored section, build the tree in your route instead and pass it to
DocsPage.
ctx.addMdxComponents({ Name: specifier }) — components every MDX page can use without an
import, by module specifier (a package export or an absolute path) whose default export is the
component. The runtime imports them through a virtual module, which is why a component object
cannot be passed. A plugin's entry replaces a default of the same name.
A malformed entry, or a component passed as an object, fails the build with a message naming
the plugin rather than registering nothing.
Building the Search Index
Full-text search uses the built HTML, via Pagefind. The integration
runs it in astro:build:done and writes the index to dist/pagefind/.
RUn pnpm run build to make search work in pnpm run dev. Dev serves dist/pagefind/ off disk,
so results describe the last build; rebuild while dev runs and the next search picks it up. Until
that first build the palette says search needs one.
What gets indexed is set by the markup, not by config:
data-pagefind-body | the <article> | Bounds the index to page content |
data-pagefind-ignore | header, sidebar, TOC, action row | Keeps chrome out of every result |
data-pagefind-meta | the <h1> and the <article> | Carries title and the group label |
The group value is the section's label from _group.yaml, so search headings and the sidebar
cannot drift apart.
The control itself is a command palette in the header, opened by click, ⌘K or /. Fill the
header's search slot to replace it, or set search.provider: 'none' to drop it entirely.
Versions
On by default. The build makes one frozen copy of the docs per older release group and serves it
under /v<group>/, adds a version menu under the site name in the sidebar, puts a banner and
noindex on every old page, and scopes search to the page's version. Each release redirects to its
group's copy.
docs({
versions: {
source: 'tags',
tags: 'v*',
granularity: 'minor',
folders: { '2.2.0': 'archive/v2.2' },
current: '4.0.0',
},
});
docs({ versions: false });
Where old versions come from
A version is a copy of the docs as they were at a release. There are two ways to keep one, and
source picks between them.
source: 'tags' (default): copied from each release tag at build time. Push a tag and that
release has a version; there is nothing else to run. It works when two things are true:
- the docs live in the same repository as the release tags, and everything a page shows is
committed by the time the tag is pushed;
- the host's clone has the tags. GitHub Actions with
fetch-depth: 0 does; Vercel's clone does
not.
With no matching tags the site is dormant and builds as if versions were off.
source: 'folders': committed copies, saved when each release ships. A release job copies the
docs into a folder such as archive/v2.5, lists it in folders, and opens a pull request; merging
it adds the version. The build reads only files in the branch, so it needs no tags and builds the
same versions on every host. The current release is the highest folder. Use it when a tag cannot
carry the version:
- part of a page only exists after the release build runs (integrity-py's wheel requirement
reports are produced by the build the tag starts, so the tag itself can only hold placeholders);
- the release tags live in another repository (a docs repo for a product released elsewhere);
- the host has no tags (Vercel).
source: 'tags' source: 'folders'
tag v2.5.0 ─▶ build copies the docs tag v2.5.0 ─▶ release job saves archive/v2.5 ─▶ PR
at that tag merged ─▶ build reads the folders
The cost of folders is the release job and the pull request each release, and a copy of the docs
per version in the repository. The cost of tags is that the host needs them and the tag must hold
the whole version.
folders also works with source: 'tags', for releases that predate the docs site: their tags
hold pages the site cannot build, so convert them once, commit the folder and list it. The newer
releases keep coming from their tags.
What to know
current is never read from your package.json. Set it from the product you document, or
leave it to the newest release in the source.
- A tag counts when it ends in
MAJOR.MINOR.PATCH. Any prefix is allowed, so v1.2.3,
1.2.3, sdk-v1.2.3 and @scope/pkg@1.2.3 all work. Prereleases and anything else are
skipped with a warning naming the tag.
- An archived page is a document, not an app. Prose, headings, tables and code samples are
kept; live component examples are not rendered, because an old page's imports bind it to
today's library. Every archived page opens with a strip naming its version and linking the
latest.
- Old versions are read-only. A wrong page is fixed by a new release.
- Moving
granularity between minor and patch does not break published URLs: every grouping
coarser than the one you set also resolves, so /v3/ and /v3.9/ both work at either setting.
Moving to major does break them. Only groupings at or coarser than the setting are emitted,
so at major nothing emits v3.9 and every published /v3.9/ URL retires.
With source: 'tags':
- CI needs the tags: check out with
fetch-depth: 0.
- If a tag lands one deploy after its bump and
current is inferred, the / label is one
release behind for that deploy. The content is current; the label heals on the next tag.
With folders, under either source:
- A folder version is served, listed and redirected exactly like a tag copy. It must be the newest
release of its group, and it replaces that release's tag copy when the tag has pages too.
- A folder for the current release is not an error: it waits, and is served once a newer release
makes it old.
- A release job that saves each patch must replace its group's previous folder, not add beside
it: once the group is old, a folder that is not its newest release fails the build. Until then
the build warns. To switch to
granularity: 'patch', change the job to keep every patch's
folder.
- Folder keys and directories are checked before anything else, so a bad key fails the build.
With source: 'folders':
- Tags are optional. Where they are present (CI), they add checks: a stale folder or a backport
still fails the build, a release whose folder is not saved yet is named in a warning, and so is
an older version that exists only as a tag, since hosts without tags won't have it.
- A folder above every release tag in the clone is named in a warning: a typo, or tags that were
not fetched. It does not fail the build, because a clone can have full history and only some
tags, so the build cannot tell a mistyped key from a missing tag.
- Until a new release's folder is merged, every host labels latest as the previous release and
does not yet list it as an old version. Merge the release job's pull request promptly.
- Without tags, a site keeps one address per saved folder:
/v2.0/ and /v2.0.9/ work; other
patches like /v2.0.7/ don't. Nothing links to those.
- If the repository has unrelated tags of its own, set
tags to a pattern that matches none of
them.
The DOCS_BASE family of environment variables still exists for consumers who deploy each
version as a separate build under its own base; this feature does not use them.
Development
pnpm build
pnpm test
src/*.ts is bundled for Node (it is loaded when Astro reads your config). src/runtime/** ships
as source and is compiled by the consumer's Astro — .astro files cannot be bundled. The copy is
verbatim, which is what keeps dev source-linking byte-equivalent to the published package.
The dev surface
packages/demo — the Equality docs site — is the first-class consumer, so the framework is
developed against the same site that ships. Run pnpm dev from the repo root: this package's
dev keeps dist/runtime in sync while the demo serves on port 4321.
The sync is what makes edits to src/runtime/** hot-reload. Astro resolves injected route
entrypoints through the real exports map, so dist/runtime — not src/runtime — is what Vite
actually serves. Changes to src/*.ts are a different matter: they are bundled by tsup and read
by Astro at config time, so they need pnpm build:node and a dev-server restart.
To exercise a versioned sub-path build:
DOCS_BASE=/v0.0/ pnpm --filter demo build
Then confirm nothing 404s when packages/demo/dist is served at /v0.0/.
Fixtures
tests/fixtures/basic is a separate, deliberately minimal consumer used for assertions — it
contains a misspelled _group.yaml key on purpose, which is why it is kept apart from the demo
rather than doubling as the dev surface.