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

@hhkaos/webmentions-widget

v0.6.0

Published

Framework-agnostic, dependency-free widget to fetch and render webmention.io mentions for the current page.

Readme

@hhkaos/webmentions-widget

Fetch and render webmention.io mentions for the current page.

Dependency-free, buildless ES modules. One implementation shared by every site instead of a copy per repo — see #1 for the history.

Why it exists

Three sites had grown three near-identical copies of "query mentions.jf2, dedupe, render a facepile and a reply list". When webmention.io started returning intermittent 502s in September 2026, every copy had the same flaw: a single un-retried fetch, and a widget that hides itself on failure. The 502 comes from nginx with no Access-Control-Allow-Origin header, so in a browser it surfaces as an opaque Failed to fetch — and the section silently vanished on all three sites at once.

So this package builds the fixes in once:

  • Retries with exponential backoff on network errors and 5xx.
  • Automatic fallback from /api/mentions.jf2 to the older /api/mentions.json, reshaped into the same entry format.
  • error is distinct from empty, so a caller can keep server-rendered markup on screen during an outage instead of blanking the section.
  • Target URL variants — www/no-www, trailing slash or not, locale prefixes. webmention.io matches targets by exact string, so this is the single most common cause of "the mention exists but does not show up".

Install

npm install @hhkaos/webmentions-widget

Or load it straight from a CDN — always pin the version, never @latest:

<script type="module">
  import {renderWebmentions} from 'https://esm.sh/@hhkaos/[email protected]/render';
</script>

Vanilla usage

<section class="webmentions h-feed" id="webmentions" hidden>
  <h2>Mentions</h2>
  <div class="webmentions__facepile" id="webmentions-facepile" hidden></div>
  <ol class="webmentions__list" id="webmentions-list"></ol>
</section>

<script type="module">
  import {renderWebmentions} from '@hhkaos/webmentions-widget/render';

  renderWebmentions({
    container: '#webmentions',
    facepile: '#webmentions-facepile',
    list: '#webmentions-list',
    // targets default to <link rel="canonical"> expanded into every variant
    facepileMode: 'grouped',
    labels: {
      'like-of': {en: 'like', es: 'me gusta'},
      'in-reply-to': {en: 'replied', es: 'respondió'},
      viewSource: {en: 'View source', es: 'Ver original'},
    },
    onError: (error) => console.warn('[webmentions]', error),
  });
</script>

A label can be a plain string, or a {en, es} map — which renders one <span class="i18n-en"> / <span class="i18n-es"> per language, for sites that ship both and toggle with CSS.

facepileMode: 'grouped' renders a separate like / repost / bookmark group with a count and a glyph. 'flat' (the default) renders one merged pile.

React / Docusaurus usage

import {getCanonicalTargets} from '@hhkaos/webmentions-widget';
import {Webmentions} from '@hhkaos/webmentions-widget/react';
import {useLocation} from '@docusaurus/router';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';

export default function SiteWebmentions() {
  const {pathname} = useLocation();
  const {siteConfig} = useDocusaurusContext();
  const targets = getCanonicalTargets({
    siteUrl: siteConfig.url,
    pathname,
    i18n: siteConfig.i18n,
  });

  return <Webmentions targets={targets} locale="es" />;
}

useWebmentions(targets, options) is exported separately if you want the data without the markup. It returns {status, groups, error} where status is idle | loading | success | error.

Build-time snapshot (recommended)

By default the widget fetches in the browser, which costs webmention.io one request per visitor per page view — and leaves the section empty whenever their API is down. A snapshot inverts that: fetch once per day in CI, commit the result, and serve it as data.

npx webmentions-snapshot --domain example.com --out src/data/webmentions.json

Domain-wide queries need an API token (webmention.io → Settings → API Key), read from WEBMENTION_IO_TOKEN. The command fetches only what is new since the last run (since_id), waits between pages, and leaves the existing file untouched if the API errors — a bad fetch never replaces good data.

Then hand the snapshot to the component:

import snapshot from '@site/src/data/webmentions.json';

<Webmentions targets={targets} snapshot={snapshot} />

The component narrows the whole-site snapshot to the current page locally and renders with no network request at all. Pass revalidate to opt back into a live fetch on top (the snapshot renders first either way, and a failed revalidation never blanks a section the snapshot could fill).

Two things this buys beyond politeness: the section survives a webmention.io outage, and the committed JSON is a durable copy of your mentions if the service ever disappears.

Saying how fresh it is

A snapshot is by definition a little behind. Pass an updated label and the widget dates what it is showing:

<Webmentions
  targets={targets}
  snapshot={snapshot}
  locale="es"
  labels={{updated: {en: 'Updated', es: 'Actualizado'}}}
/>

It renders only when the mentions came from a snapshot and there is at least one to qualify — on a live fetch the data is current and dating it would mislead.

While the snapshot is recent the timestamp reads as "3 hours ago"; once it is over a day old it becomes an absolute date. Either way the exact moment is one hover or one click away, and the <time datetime> attribute always carries the machine-readable value. renderUpdated={(iso, label, exact) => …} takes over the wording entirely.

The widget owns this because it is the only layer that knows which source the mentions came from; the host owns the copy.

API

fetchWebmentions(options)

| Option | Default | Notes | | --- | --- | --- | | targets | from <link rel="canonical"> | array of exact target URLs | | apiUrl | .../api/mentions.jf2 | | | jsonApiUrl | .../api/mentions.json | used only for the fallback | | perPage | 20 | | | sortBy / sortDir | published / down | | | retries | 2 | extra attempts per endpoint | | retryDelayMs | 400 | doubles each attempt | | fallbackToJson | true | | | signal | — | AbortSignal; aborts are never retried | | fetch | globalThis.fetch | injectable, for tests or a proxy |

Resolves to an array of jf2 entries. Rejects with a WebmentionFetchError (carrying .status and .attempts) once every attempt is spent. A 4xx is not retried — it will not fix itself — but the fallback endpoint is still tried.

getCanonicalTargets({siteUrl, pathname, i18n, localePrefixes})

Expands one page into every target string webmention.io might have stored it under.

groupWebmentions(mentions)

Returns {interactions, threads, byProperty, counts, total}. Facepile entries are deduped per author per property, so one person's like and repost both survive. Duplicate wm-ids from overlapping target queries are dropped.

getMentionContent(mention, {maxLength, parseHTML})

Finds the anchor in the source page that points back at you and quotes the sentence around it, rather than excerpting from the top of the post. Falls back to content.text. Pass parseHTML to run outside a browser.

getMentionAuthor(mention)

Returns {name, url, photo, initial, hue}. name falls back to the source host, and initial/hue are there so a mention whose h-card has no photo — a plain blog, or your own site — still renders an avatar instead of a gap.

getMentionSourceUrl(mention, content)

Appends a #:~:text= fragment so the source link lands on the quoted sentence.

renderWebmentions(options) / renderGroups(groups, options)

Imperative DOM rendering. renderGroups paints an already-fetched feed, so a site can hydrate from a build-time snapshot without touching the network.

Every remote string is written with textContent. Nothing in this package ever assigns remote HTML.

Avatars

Both renderers always emit an avatar element per mention. With author.photo it is an <img> carrying the photo class (webmention-photo in React, webmentions__photo in the DOM renderer); without one it is a <span> holding the author's initial, carrying that same class plus is-initial and a --webmention-avatar-hue custom property (0–359, stable per author name).

The package ships no CSS. Style the fallback yourself — the hue is offered, not imposed:

.webmention-photo {
  border-radius: 50%;
  flex: 0 0 40px;
  height: 40px;
  width: 40px;
}

.webmention-photo.is-initial {
  align-items: center;
  background: hsl(var(--webmention-avatar-hue, 220) 45% 92%);
  color: hsl(var(--webmention-avatar-hue, 220) 45% 30%);
  display: flex;
  font-weight: 700;
  justify-content: center;
}

Notes on webmention.io quirks

  • The jf2 feed says mention-of; some payloads say mention. normalizeProperty folds them together, so only ever branch on mention-of.
  • Bridgy mangles emoji into runs of ? and U+FFFD when extracting plain text. stripMojibake removes the debris — the emoji is not recoverable.
  • Responses are sometimes invalid JSON: source content is copied into string literals unescaped, so a backslash or a raw newline in a mention's content makes the whole payload unparseable. parseWebmentionJson repairs those two cases; valid payloads pass through untouched.

Development

npm test

No dependencies, no build step. Tests run on node:test against a ~90-line fake DOM in test/fake-dom.js.

License

MIT