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

@bitbaum/listkit

v0.2.2

Published

A list as a query: a closed set of facet kinds, a URL codec that copies rather than rebuilds, a comparator that puts missing values last, and page arithmetic that cannot produce a negative offset. No markup, no React, no HTTP client.

Readme

@bitbaum/listkit

A list as a query. Filtering, sorting, searching and paging are the same problem everywhere, and they were solved separately everywhere.

A survey of twelve repositories in this fleet on 2026-09-11 found:

| what | count | | --- | --- | | incompatible filter-state shapes | 12, no two alike | | hand-built URL builders | ~34 | | copies of "toggle a value in a set" | ~25, in two encodings | | debounces, at five different delays | ~11 | | copies of Math.ceil(total / pageSize) | ~11, three in one repo | | repos that escape % and _ before a SQL LIKE | 3 of 7 that need to |

Two of the toggle copies were character-for-character identical in repos that share no code. One repo wrote the same three-line body eight times for eight facets, plus eleven more for its boolean flags — about a hundred and twenty lines expressing one idea.

Four live bugs were among the URL builders, and this codec exists so they cannot be written again:

  • one built new URLSearchParams() from scratch, so applying a preset silently wiped the reader's active search and five other facets;
  • one changed a date range without clearing page, landing the reader on page 4 of a two-page result;
  • one preserved exactly three params by name, so every param added later had to be remembered in six separate template strings;
  • one used push rather than replace for a debounced search box, so the back button walked backwards through every intermediate spelling of the word.

What it ships

The decisions, and nothing else.

import { parseQuery, writeQuery, applyQuery, type ListSpec } from "@bitbaum/listkit";

const spec: ListSpec<Project> = {
  facets: [
    { key: "kind", kind: "one", value: (p) => p.kind, options: ["product", "client", "demo"] },
    { key: "status", kind: "many", value: (p) => p.status, options: ["live", "prospect"] },
    { key: "nosite", kind: "flag", value: (p) => !p.site },
  ],
  search: { text: (p) => [p.name, p.description, p.host] },
  sorts: [
    { key: "name", by: [(p) => p.name] },
    { key: "newest", by: [(p) => p.since, (p) => p.name] },
  ],
  defaultSort: "name",
  defaultPageSize: 25,
};

const query = parseQuery(searchParams, spec); // validated, clamped
const { rows, matched, page, counts } = applyQuery(projects, spec, query);
const url = `?${writeQuery(searchParams, next, spec, query)}`;

Five facet kinds, and no magic sentinel

one · many · flag · range · ordinal. The value for "no filter" is the empty selection, never a magic string. One surveyed repo used the translated label ("Alle") as its all-value, which made filter identity depend on the reader's language.

Rules the tests pin

  • An empty selection filters nothing, not everything.
  • Missing values sort last in both directions. A record with no date is not the oldest record.
  • ?page=0 can never become a negative offset, and an empty list still has one page rather than "page 1 of 0".
  • Facet counts are computed with that facet's own selection lifted, so while filtering by kind=product the count beside kind=demo still says how many demos exist, instead of zero.
  • Writing copies the params you were given. A locale or a referrer survives.
  • page is dropped when the result set changes, kept when only the page moves.
  • A superseded async search resolves to undefined, so a slow "ab" cannot overwrite a fast "abc".
  • Every search word must match, folded, and a non-match scores 0.
  • A reader's chosen sort is never overridden by relevance.

Search that finds what people type

  • Every word, any order. bike sell finds "Sell my old bike".
  • Accents and ß fold. zurich finds "Zürich", strasse finds "Straße".
  • Best match first. While a search is active and the reader has not picked a sort, results rank by where a word matched — search.text lists fields most important first — and how well: the whole field, its start, a word's start, anywhere inside. A phrase match earns a bonus; ties keep the default order. A sort the reader chose wins. ?sort=relevance asks for it explicitly; search: { rank: false } opts a list out.
  • One rule for SQL too. searchTerms(q) gives the folded words; apply one likeContains per term, ANDed, and the database agrees with the array. (Fold the column the same way — unaccent() in Postgres.)

No stemming, no typo tolerance, no index: that is a search engine's job.

What it deliberately does not ship

Markup, tokens, React, a search engine, or an HTTP client.

Nine token vocabularies and six chip treatments were found among those same repos, several different on purpose — one marketplace is monochrome by design, one product reserves an accent colour for Bitcoin UI, one is the only RTL-aware surface in the fleet. The fleet has already run the experiment where a shared renderer owns the markup: it reached two consumers out of twenty and shipped the same two defects to both, unfixable downstream because a consumer cannot patch markup it does not own.

Centralize the rule; render it locally.

The execution engine is also yours. applyQuery is the in-memory engine, for bounded collections. The same ListSpec describes what a SQL builder should do with a large one — escapeLike and likeContains are exported for exactly that. Forcing one execution model would serve one repo and strand the rest.

Install

pnpm add @bitbaum/listkit

Develop

pnpm install
pnpm run verify   # format, lint, typecheck, build, test

Tests import the package by name, so a broken exports or files map fails here rather than at the first consumer's install.

MIT.


Part of bitbaum — AI-native products on open infrastructure, built in Zürich. Every package here lists the apps that use it: which apps use listkit.

Git releases include the compiled dist/ artifact and require no consumer-side prepare build. Package CI rebuilds it and refuses a mismatch with committed output, so consumers can retain pnpm's default build-script restrictions. The same public exports work for Git and npm installs.