npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

satteri-heading-link

v0.3.0

Published

Accessible heading permalinks for Sätteri Markdown and Astro.

Downloads

243

Readme

🔗 satteri-heading-link

A Sätteri HAST plugin that adds accessible, keyboard-operable permalinks beside headings at build time.

[!WARNING] This package is experimental. Its API, generated HTML, CSS class names, and behavior may change incompatibly between releases.

View live examples.

✨ Features

  • Adds a real link beside every selected heading that already has an id
  • Keeps the heading and permalink as sibling elements
  • Names the icon-only link with the visible heading through aria-labelledby
  • Supports text, HAST, callback, and CSS-owned icons
  • Supports end and start placement
  • Provides optional preset CSS without injecting styles
  • Does not add browser JavaScript or generate heading IDs

📦 Installation

pnpm add satteri-heading-link satteri

Node.js 22 or newer is required.

🚀 Sätteri setup

The plugin runs after a heading-ID plugin. This example uses an explicit heading attribute so the core Sätteri pipeline is self-contained:

import { markdownToHtml } from "satteri";
import { satteriHeadingLink } from "satteri-heading-link";

const result = await markdownToHtml("## Installation {#installation}", {
  features: { headingAttributes: true },
  hastPlugins: [satteriHeadingLink()],
});

In a larger pipeline, register your slug or heading-ID plugin before satteriHeadingLink(). Headings without IDs are skipped by default. The plugin does not invent slugs, so IDs stay consistent with your table of contents and metadata.

🌌 Astro setup

Astro's Sätteri processor provides the heading-ID plugin separately. Register the two plugins explicitly and keep the ID plugin first:

pnpm add @astrojs/markdown-satteri astro satteri satteri-heading-link
import { satteri, satteriHeadingIdsPlugin } from "@astrojs/markdown-satteri";
import { defineConfig } from "astro/config";
import { satteriHeadingLink } from "satteri-heading-link";

export default defineConfig({
  markdown: {
    processor: satteri({
      hastPlugins: [() => satteriHeadingIdsPlugin(), satteriHeadingLink()],
    }),
  },
});

The factory form creates a fresh slugger for each document. Keeping the plugins in this order also keeps rendered IDs and Astro's getHeadings() metadata in sync.

🎨 Styling

The plugin emits class names but does not inject CSS. Import the optional preset once from a shared Astro layout or another global stylesheet entry:

---
import "satteri-heading-link/preset.css";
---

You can omit the preset and define the styles yourself. The generated elements use these stable classes for the current release:

  • .satteri-heading-link
  • .satteri-heading-link--h1 through .satteri-heading-link--h6
  • .satteri-heading-link__heading
  • .satteri-heading-link__link
  • .satteri-heading-link__icon

The preset provides GitHub Markdown-inspired heading typography, spacing, and rules, plus a visible :focus-visible outline, a larger pointer target, icon reveal on hover, and wrapping that keeps the heading's final word with its icon. The preset provides the default end presentation; start placement requires site-specific CSS. The preset does not provide the accessible name.

Because the heading and permalink share a wrapper, the wrapper owns visual heading styles such as font size, margin, padding, and borders. The child heading inherits those styles. If you omit the preset, apply your heading theme to .satteri-heading-link--h1 through .satteri-heading-link--h6 and reset the child heading's block styles. Applying the same relative font size to both the wrapper and child heading will compound the size.

.satteri-heading-link--h2 {
  margin-block: 1.5rem 1rem;
  border-block-end: 1px solid #d1d9e0;
  font-size: 1.5em;
}

.satteri-heading-link > .satteri-heading-link__heading {
  display: inline;
  margin: 0;
  padding: 0;
  border: 0;
  color: inherit;
  font: inherit;
}

The preset applies scroll-margin-block-start: 1.5rem to the child heading, which retains the generated id. Override it when a fixed header requires a larger offset:

.satteri-heading-link__heading {
  scroll-margin-block-start: 5rem;
}

⚙️ Options

satteriHeadingLink({
  levels: [2, 3],
  placement: "end",
  accessibleName: ({ text }) => `Link to section “${text}”`,
  icon: "#",
  missingId: "warn",
});

| Option | Default | Description | | ---------------- | ------------------ | ----------------------------------------------------------------------------------- | | levels | all heading levels | Heading levels to decorate. | | placement | "end" | Places the link after or before the heading. | | accessibleName | "heading" | Uses aria-labelledby, or a callback for a localized aria-label. | | icon | built-in SVG | Accepts a string, HAST content, callback, or false. | | missingId | "skip" | Handles selected headings without a non-empty ID: "skip", "warn", or "error". |

The library owns the generated classes, heading ID, link href, accessible-name attributes, icon aria-hidden, and wrapper marker. Use the stable classes and an enclosing site element to scope custom CSS. Generated wrapper, heading, and link properties are intentionally not configurable so their semantics remain under library control.

Accessible names

The default output references the visible heading:

<a href="#installation" aria-labelledby="installation">
  <!-- decorative icon -->
</a>

Use a callback when the link needs a localized or more descriptive label:

satteriHeadingLink({
  accessibleName: ({ text }) => `「${text}」へのリンク`,
});

If the callback returns an empty string, the plugin reports a warning and falls back to the heading text.

Icons

The default icon is a decorative SVG informed by Starlight's link-alt icon. A string is useful for a simple symbol:

satteriHeadingLink({ icon: "#" });
satteriHeadingLink({ icon: ({ level }) => (level === 2 ? "§" : "#") });

HAST elements and arrays of HAST content are also accepted:

satteriHeadingLink({
  icon: {
    type: "element",
    tagName: "svg",
    properties: { viewBox: "0 0 24 24", focusable: "false" },
    children: [],
  },
});

Use icon: false when your own CSS draws the visual icon. The icon wrapper is always aria-hidden because the link gets its name from the heading.

♿ Accessibility

The permalink is a native <a href> element, so it is keyboard-operable and is announced as a link by assistive technology. Its accessible name comes from the visible heading through aria-labelledby; the decorative icon is not announced.

The heading and link are siblings rather than putting a link inside the heading. This keeps heading navigation clean, avoids nested links when a heading already contains a link, and leaves the heading's accessible name as its title.

The HTML remains meaningful without the preset. If you provide custom CSS, keep the link in the tab order, preserve a visible focus state, and keep a usable pointer target even when the icon is visually hidden.

🧭 Design choices

rehype-autolink-headings is a flexible, general-purpose Unified plugin with several output structures and AST customization options. This package is not a port of that API. It standardizes a Starlight-informed, accessible pattern:

  • The heading and permalink remain siblings in one wrapper.
  • The permalink is keyboard-focusable and named from the visible heading.
  • The plugin owns the semantic HTML while exposing a small, content-focused API.

Choose this package when you want that pattern and its accompanying CSS as a coherent default. It requires heading IDs from another plugin and does not add browser JavaScript.

See THIRD_PARTY_NOTICES.md for the Starlight reference commit and license notice.

🔒 Security and limitations

Custom icon HAST nodes are emitted into the generated HTML. Do not construct them from untrusted input without an appropriate sanitizer.

The plugin only decorates headings with existing IDs and does not run browser JavaScript. It does not provide slug generation, table-of-contents generation, or client-side copy behavior. Arbitrary HAST properties for the generated wrapper, heading, and link are intentionally not accepted; this keeps their semantics under the library's control.

🛠️ Development

vp install
vp check
vp test
vp pack

📄 License

MIT. Starlight's MIT notice is included in THIRD_PARTY_NOTICES.md.