Esc

comark-links

Links

Say which links open a new tab and warn before they do, and give every section a link to itself.

Playground

Point at the step people ask about, not the top of the page.

Internal teams only.

Two plugins, each one on its own:

  • anchors() puts a # after each h2, h3, and h4, linking to the heading itself, so readers can share a section. It shows when the pointer is on the heading, or always on a touch screen. The link sits beside the heading, not inside it, so screen readers don't read "#" as part of the heading's name.
  • external() writes target and rel on links to other sites while parsing. It opens nothing in a new tab unless you ask, and when a link does open one, it adds ↗ and "(opens in a new tab)" for screen readers, so nobody is surprised by a new tab.

Comark gives headings ids but draws no link to them, and changes a link's target only when you write {target="_blank"} on it.

A heading needs an id for a link. Comark gives every heading one from its text, or the one you write with {#id}:

## Install with pnpm {#install}
md

With headingIds: false and no {#id}, a heading gets no link.

Each heading with a link renders as Starlight's do:

<div class="cl-heading">
  <h2 id="setup">Setup</h2>
  <a class="cl-anchor" href="#setup"><span aria-hidden="true">#</span><span class="cl-sr">Section titled "Setup"</span></a>
</div>
html

A link keeps the target or rel you write on it:

[Status page](https://status.example.com){target="_self"}
[My profile](https://mastodon.social/@onmax){rel="me"}
md

When a link to another site opens a new tab, from target or from your own {target}, it ends with the warning:

<a href="https://example.com" target="_blank" rel="noopener">Docs<span class="cl-icon" aria-hidden="true">↗</span><span class="cl-sr">(opens in a new tab)</span></a>
html

Leave target off unless you have a reason. A reader who wants a new tab can open one, and one who doesn't can't stop a link from opening it. rehype-external-links stopped adding target="_blank" by default in its version 2 for this reason.

Some pages have a reason, such as a form the reader would lose by leaving, or a playground with unsaved work. Open external links in a new tab there, and list your own hosts so links to the rest of your site stay in the tab:

external({ target: "_blank", hosts: ["example.com", "docs.example.com"] });
ts

Every link that opens a new tab gets ↗ and "(opens in a new tab)", which screen readers read after the link's text. That is WCAG's technique G201: say a link opens a new window before the reader follows it. For a site in another language, change the words:

external({ target: "_blank", label: "(se abre en una pestaña nueva)" });
ts

To mark links for search engines without changing where they open, set only rel:

external({ rel: ["nofollow", "ugc"] });
ts

By default a link is external when its href starts with http://, https://, or // and its host isn't in hosts. Pass test to decide yourself, from the href:

// Everything outside /docs, including your own blog.
external({ target: "_blank", test: (href) => /^https?:/.test(href) && !href.startsWith("https://example.com/docs") });
ts

test replaces the default, so hosts no longer counts once you pass it.

The options go under links in comarkUi(), as anchors and external, or to anchors() and external() when you use them alone. false turns one off.

Option Default What it does
levels [2, 3, 4] The heading levels that get a link
label Section titled "…" What screen readers hear for the link, from the heading's text
icon "#" What the link shows
Option Default What it does
target false Where external links open, such as "_blank"
rel [] Words added to external links' rel, such as ["nofollow"]
hosts [] Your own hosts, such as "example.com", whose links count as internal
test http and https links not on hosts Which links are external, from their href
icon true Draws ↗ after a link that opens a new tab
label "(opens in a new tab)" What screen readers hear after that link; "" leaves it out

noopener joins rel whenever the target is _blank. A link keeps the target or rel you write on it, as External links shows.

A hosts entry matches one host exactly, so list www.example.com and docs.example.com if you link to them. By default, links that start with /, #, or mailto: are never external.

comark-ui brings comark-links and registers both plugins. To use it alone, install it, register the plugins you want, and pass the renderer's components:

pnpm add comark @comark/html comark-links
import { createHtmlRenderer } from "@comark/html";
import { anchors, components, external } from "comark-links/html";

const render = createHtmlRenderer({
  plugins: [anchors(), external({ target: "_blank", hosts: ["example.com"] })],
  components,
});
ts

Each plugin works alone, in any order. The package has no default export, so import the plugins by name.

external() does all its work while parsing, so its target and rel reach any renderer. The components draw the heading links, and add the styles once per document.

For Vue, React, or Svelte, import from comark-links/vue, comark-links/react, or comark-links/svelte; the components add the styles when they mount. Import comark-links/style.css with your styles so a server-rendered page doesn't shift. Renderers covers using it with other plugins.

The components replace how h1 to h6 render, and draw a heading as it would be without them unless anchors() gave it a link. The heading stays where it was in the document's tree, so a table of contents built from the tree still finds it.

The rules sit in the comark CSS layer, so any plain rule of yours wins. The classes are .cl-heading around a heading and its link, .cl-anchor on the link, .cl-icon on the arrow, and .cl-sr on the text only screen readers hear. The heading links read these shared tokens:

Token Used for
--comark-muted The # link
--comark-foreground The # link under the pointer
--comark-ring The focus ring on the link
--comark-radius The focus ring's corners

.cl-heading is a flex row, so the heading keeps its own font and margins, and the link sits on its first line. Two things change for a heading inside it:

  • Its margins no longer merge with the paragraph's above it, so the gap can grow. Set the margins on .cl-heading and zero them on the heading if that matters.
  • Its border or background ends at the end of its text. Move them to .cl-heading:has(> h2).

To always show the links, even with a mouse:

.cl-anchor {
  opacity: 1;
}
css