New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@eqtylab/docs

Package Overview
Dependencies
Maintainers
6
Versions
27
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@eqtylab/docs

Astro documentation framework built on the Equality design system

latest
Source
npmnpm
Version
0.17.0
Version published
Weekly downloads
909
15.06%
Maintainers
6
Weekly downloads
 
Created
Source

@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
// astro.config.mjs
import docs from '@eqtylab/docs';
import { defineConfig } from 'astro/config';

// TODO: change this to your project's.
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 }],
      },
    }),
  ],
});
// src/content.config.ts  — required; Astro cannot inject collections
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:

OptionReplaceTurn off
faviconfavicon: '/mine.svg'—
logologo: { src: '/mine.svg', alt: 'Acme' }logo: false (the header shows title as text, then "Docs")
ogImageogImage: '/share.png' or a URLogImage: 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.

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.

Footer row hasShows
prefix, label and href"Install it from npm" with the label linked
label and href"npm"
label onlyPlain text, exactly as written, such as "Built with Astro"
iconThe 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

WriteFor
lucide:<name>Any Lucide icon
simple-icons:<name>Any logo from Simple 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.

The site isTo get thereIts menuOther sites' menus
PublicAdd it to eqtyDocsSites in src/sites.ts and releaseThe public sites, in list orderList it once they upgrade
PrivateNothingItself at the top, then the public sitesNever 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.

# src/content/docs/getting-started/_group.yaml
label: Getting Started
order: [installation, usage, themes] # filesystem names, not titles
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.

BetweenSpace
The page header's rule and the content32px
Above any h2, title or label48px
A label and its first h324px
Above an h3 after text or a block32px
Above h4 to h624px
An h2 and its own text16px
An h3 to h6 and its own text12px
Below paragraphs, lists, code blocks, tables, tabs, alerts, rules16px
List items8px
Above the article footer, and from it to the site footer64px
The previous/next links and the edit and report links, each side32px
The site footer's padding24px

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:

Authored asRenders as
Code fencesCodeBlock
Markdown tablesTableContainer / TableHeader / TableBody / TableRow / TableHead / TableCell
<Alert>Alert, as an aside
<Tabs>Tabs, server-rendered
Linksbase-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:

AttributeWhereEffect
data-pagefind-bodythe <article>Bounds the index to page content
data-pagefind-ignoreheader, sidebar, TOC, action rowKeeps chrome out of every result
data-pagefind-metathe <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' (default) | 'folders': where old versions come from
    tags: 'v*', // git tag glob; tags not ending in MAJOR.MINOR.PATCH are skipped
    granularity: 'minor', // 'major' (default) | 'minor' | 'patch'
    folders: { '2.2.0': 'archive/v2.2' }, // committed copies, keyed by release
    current: '4.0.0', // optional; defaults to the newest release in the source
  },
});
// or
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    # guard + node bundle + runtime copy + declarations
pnpm test     # nav ordering, prev/next, breadcrumbs, TOC, prose scope, link resolution

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.

Keywords

astro-integration

FAQs

Package last updated on 06 Nov 2026

Related posts