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

@supersuit/wiki-comments

v0.1.3

Published

Highlight-to-comment, general and voice comments, and a bottom-pinned action bar for wikis.

Readme

@supersuit/wiki-comments

Comments for a wiki page. A reader selects words in the article and comments on them, writes a general comment under the article, replies to someone else's comment, or says any of these instead of typing it: a voice note, with the words written into the comment box as they speak where the browser can. Every action on the page sits in one bar pinned to the bottom of the screen, where it never fights a phone's own copy menu.

The package knows how comments work: the record of a comment, its anchor to the quoted words, every rule (who may read, post and delete, the limits), the request handler, and the whole interface. The wiki says who is reading, who owns the wiki, which pages a reader may see, where comments are kept, and how it looks.

30 seconds

npm install @supersuit/wiki-comments firebase-admin

A Vercel function (api/comments.ts), for a wiki that is not a Next.js app:

import { createCommentsHandler } from '@supersuit/wiki-comments/server'
import { host } from '../lib/comments-host'

const handle = createCommentsHandler(host)
export const GET = handle, POST = handle, DELETE = handle

A Next.js route (app/api/comments/route.ts) is the same three lines:

import { createCommentsHandler } from '@supersuit/wiki-comments/server'
import { host } from '@/lib/comments-host'

export const runtime = 'nodejs'
const handle = createCommentsHandler(host)
export const GET = handle, POST = handle, DELETE = handle

On the page, mount WikiComments once, beside the article and outside the article element:

import { WikiComments } from '@supersuit/wiki-comments/client'
import '@supersuit/wiki-comments/styles.css'

// One thread per page: /x and /x/ are the same page.
const page = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname

<article ref={articleRef}>{content}</article>
<WikiComments apiBase="/api/comments" page={page} articleRef={articleRef} />

It reads the article's text to find quoted passages, and its own list repeats those quotes, so the list must not be inside what it searches. A signed-out reader (the host answers 401) sees nothing at all: no list, no bar.

Docusaurus

Wrap the doc layout (src/theme/DocItem/Layout/index.tsx), mount through BrowserOnly, and key the mount on the page so each page starts fresh:

import { useEffect, useRef, useState } from 'react'
import Layout from '@theme-original/DocItem/Layout'
import BrowserOnly from '@docusaurus/BrowserOnly'
import { useLocation } from '@docusaurus/router'
import { WikiComments } from '@supersuit/wiki-comments/client'
import '@supersuit/wiki-comments/styles.css'

function Comments({ page }: { page: string }) {
  const articleRef = useRef<Element | null>(null)
  const [ready, setReady] = useState(false)
  // After commit, so a client-side navigation finds the NEW article, not the one leaving.
  useEffect(() => { articleRef.current = document.querySelector('article'); setReady(true) }, [])
  return ready ? <WikiComments apiBase="/api/comments" page={page} articleRef={articleRef} /> : null
}

export default function LayoutWrapper(props: React.ComponentProps<typeof Layout>) {
  const { pathname } = useLocation()
  // One thread per page: /x and /x/ are the same page.
  const page = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname
  return (
    <>
      <Layout {...props} />
      <BrowserOnly>{() => <Comments key={page} page={page} />}</BrowserOnly>
    </>
  )
}

The comments section lands after the <article>, never inside it. The API is the Vercel function above, deployed with the site.

Three entry points

  • @supersuit/wiki-comments: the types and the rules, pure and safe anywhere: TextAnchor, WikiComment, PublicComment, Reader, findText, textAnchorFrom, quoteLabel, the limits (MAX_TEXT, MAX_AUDIO_MS, MAX_QUOTE_CHARS, CONTEXT_CHARS), COMMENT_ID, and the rules (parseNewComment, canDelete, pageKey, newCommentId).
  • @supersuit/wiki-comments/server: createCommentsHandler(host), firestoreStore, memoryStore, cloudStorageAudio, and the WikiCommentsHost, CommentsStore and Bucket types. Web Request in, Response out; no framework.
  • @supersuit/wiki-comments/client: WikiComments, and the parts it is made of for a wiki that wants to assemble its own: ActionBar, CommentsSection, Composer, AudioPlayer, useRecorder, RecordButton, useSelectionIn, commentsApi, the highlight helpers, and DEFAULT_STRINGS. Plain React 19, marked 'use client'.
  • @supersuit/wiki-comments/styles.css: the interface's styles.

The host

WikiCommentsHost, exported from /server, documents every member in its JSDoc. In short:

  • reader(req): who is reading, from the wiki's own session, or null (answers 401). The server sets every comment's author from this. Nothing the browser sends decides identity.
  • canRead(reader, page): whether this reader may see this page. false answers 404, so a page the reader cannot see is never confirmed to exist.
  • isOwner(reader): the wiki's owner may delete anyone's comment or reply. Authors may delete their own.
  • store: where comments live: list, add, get, remove, and optionally removeMany (deleting a comment with replies removes them together through it, a batch in Firestore). firestoreStore(db, collection) keeps them at <collection>/<sha1(page)>/items/<id>, replies beside the comments they answer; memoryStore() is for tests and demos. A store of your own must leave out of list any row carrying deletedAt (only 0.1.0 and 0.1.1 wrote one).
  • audio: optional. Without it the voice routes answer 404 and a voice comment is refused with 400. The Record control is drawn wherever the browser can record, so a wiki with no audio should not leave its readers a Record button that cannot send: give it audio, or assemble the parts without RecordButton. cloudStorageAudio(bucket, prefix) signs a PUT valid 10 minutes and a GET valid an hour, under prefix. The browser uploads straight to the bucket, so the bucket's CORS must allow PUT from the wiki's origin with the Content-Type and x-goog-content-length-range headers.
  • onComment(c): optional. Awaited after a comment is saved (to notify the owner, say); a throw is logged and never fails the request.
  • log(msg, err): optional. Defaults to console.error.
import { getFirestore } from 'firebase-admin/firestore'
import { getStorage } from 'firebase-admin/storage'
import { cloudStorageAudio, firestoreStore, type WikiCommentsHost } from '@supersuit/wiki-comments/server'

export const host: WikiCommentsHost = {
  reader: async (req) => yourSession(req),            // { id, name } or null
  canRead: async (reader, page) => true,
  isOwner: (reader) => reader.id === process.env.WIKI_OWNER_ID,
  store: firestoreStore(getFirestore(), 'wiki-comments'),
  audio: cloudStorageAudio(getStorage().bucket(), 'wiki-comments/'),
}

The rules the handler holds

A comment is at most 4000 characters, a voice note at most three minutes and only audio/webm or audio/mp4, a quote at most 500 characters. Comments are never edited.

Replies are one level deep. A reply is a comment with replyTo, the id of a top-level comment on the same page; it has text, a voice note or both, under the same limits, and never an anchor. A reply to a reply, to a comment on another page, or to one that does not exist is refused with 400, and so is a reply carrying an anchor. Replies are listed oldest first, under their comment.

Delete is for good. The comment's document and its recording are both removed, and nothing is left in its place. Deleting a top-level comment removes every reply to it, and their recordings, with it; deleting a reply removes only that reply. The author or the wiki's owner may delete. A document an earlier version marked deletedAt is never listed, and a DELETE removes it for good.

A voice path is accepted only if its shape matches this page and this reader's prefix (<sha1(page)>/<sha1(reader)>/<22 characters>.webm|m4a), which is the shape the upload route issues. Readers never see storage paths: each voice comment in a list carries a playUrl, a GET signed for an hour, and when that has expired the client asks for a fresh one by comment id.

The wire

One route, ?page=<route> on every call:

| Request | Does | | --- | --- | | GET ?page= | { comments, me, owner }; a voice comment's audio is { mime, ms, playUrl? } | | POST ?page= | file a comment { text, anchor?, audio?, replyTo? }, answers 201 { comment } in the same public shape as GET | | DELETE ?page=&id= | delete for good, with its replies and recordings, answers 204 | | POST ?op=upload&page= | { mime } in, { path, url, headers } out: PUT the recording to url with exactly headers | | GET ?op=play&page=&id= | { url }, a signed GET for that comment's recording |

WikiComments props

| Prop | | | --- | --- | | apiBase | the route, e.g. '/api/comments' | | page | the page's canonical route; a change starts the composer empty | | articleRef | a ref to the rendered article body | | extraActions | the wiki's own bar buttons, after Comment and Record | | menu | the wiki's own items for the ••• sheet, before Copy link | | strings | any of DEFAULT_STRINGS replaced, including the bar's own more, close, pageActions, moreActions and commentOn |

Each top-level comment has a Reply button that opens a composer under it (the same composer and recorder as the one at the bottom, with Cancel); the bar's Comment button always opens the top-level composer. A comment whose quoted words are no longer on the page is kept, listed under "On an earlier version" with the quote as it was. Tapping a highlighted passage scrolls to its comment; tapping a comment's quote scrolls to the passage and flashes it.

Styling

Every colour is a --wc-* variable. The defaults sit in :where(:root), which has no specificity, so a site sets any of them on :root (or any ancestor) and wins. A dark set applies under [data-theme='dark'].

| Variable | For | | --- | --- | | --wc-ink | text | | --wc-muted | quiet text: dates, the empty line | | --wc-on-ink | text on an ink-coloured button | | --wc-hairline | borders and dividers | | --wc-surface | the bar | | --wc-sheet-bg | the ••• sheet | | --wc-backdrop | behind the sheet | | --wc-shadow | the bar's and sheet's shadow (a whole box-shadow value) | | --wc-quote | a highlighted passage | | --wc-flash | a passage flashed when its comment's quote is tapped | | --wc-error | an error line | | --wc-z | the bar's z-index |

Text inputs are 16px or larger, so iPhone Safari does not zoom into the composer.

Browser support

  • Highlights are drawn with the CSS Custom Highlight API, never written into the article: Safari 17.2+ (iOS 17.2+) and Chrome 105+. Elsewhere passages simply do not paint; selecting words and commenting on them, the list, and tapping a quote all still work.
  • Voice needs MediaRecorder and a secure context: HTTPS, or localhost. The recorder asks for audio/mp4 first, which every current browser plays back, then audio/webm;codecs=opus, else the browser's default. Where the browser cannot record, the Record control is not drawn at all. Live transcription into the comment box runs where the browser has speech recognition, except on iPhone and iPad, where running it beside the recorder can silence the voice note; there, and anywhere else without it, the note is recorded without live words.
  • Playback on iPhone: the list carries each recording's signed URL, so a tap sets the source and calls play() inside the tap itself, which iPhone Safari requires. If that URL has expired, a fresh one is fetched and the player says "Tap again to play".

Requirements

Node 20.9 or later. The root and server entries also ship as CommonJS (dist/cjs/), because a Vercel function in a project without "type": "module" is compiled to require() and Vercel's runtime refuses require() of an ES module. The client entry is ES modules only. Peers: react and react-dom 19, and firebase-admin 12 or 13 for firestoreStore and cloudStorageAudio only (optional: a host with its own store needs none).

test/consumer/ in this repository installs the packed package, type-checks against it, and sends requests through the handler. npm run check runs it.

License

MIT