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/searchkit

v0.2.3

Published

Search that finds what was meant: typo budgets by word length, prefix while typing, German spellings without umlauts, joined and split words, Algolia-order tie-break ranking, highlight ranges, query expansion for any database, and a generated typo regress

Readme

searchkit

Search that finds what was meant.

You start typing and you get where you need to go. One wrong letter in a name — the SoundCloud failure, where an artist is unfindable because you typed kayne — still finds it. Zero dependencies; React optional.

A survey of the fleet on 2026-10-12 found about 25 search boxes, and one with any typo tolerance (substrata's own engine). The rest were text.toLowerCase().includes(q) or an SQL ILIKE '%q%': no typos, no ranking, a few without escaping %. Every new site would have written a 26th.

Where it lives, and how a site adopts it

This repo, created by bitbaum/fleet's package-repos workflow from bitbaum/orangecat packages/searchkit (its history before 0.2.2 is there). A site installs a release from npm, which Dependabot moves forward on its own:

// package.json
"@bitbaum/searchkit": "^0.2.2"

It ships compiled JavaScript in dist/, committed beside the source the way @bitbaum/whenkit does, so nothing has to transpile it: Next.js, Vitest, and Node's own test runner (which refuses TypeScript inside node_modules) all load it as is. Its tests are in test/ and run with plain Node (npm test); one of them fails while dist/ is not the build of src/ (npm run build).

Every search box in the fleet should go through it. The first adopter, OrangeCat, runs it in three places: the ⌘K palette's own lists (pages, your things), /api/search (vocabulary corrections handed to Postgres, then ranking), and /discover.

What it ships

import { createSearch } from '@bitbaum/searchkit';

const people = createSearch(rows, {
  fields: [
    { name: 'name', get: p => p.name }, // most important first
    { name: 'handle', get: p => p.username },
    { name: 'bio', get: p => p.bio },
  ],
  rank: p => Math.log1p(p.followers), // last tie-break only
});

people.search('kayne wset'); // → Kanye West, with ranges to mark

Matching — what counts as the same word

| typed | finds | how | | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | cqto | Cato | one wrong letter (budget: 0 typos under 4 letters, 1 from 4, 2 from 8) | | kayne | Kanye | a swap is ONE typo (optimal string alignment) | | koki | Loki | a wrong first letter still matches — ranked as two typos | | lo | Loki | the start of a word, while typing | | bitcon | Bitcoin | a typo in the start of a word | | zurich, mueller | Zürich, Müller | accents folded; umlauts also indexed spelled out | | orangecat, orange cat | OrangeCat, Orange Cat | camelCase and separators split; adjacent words also indexed joined (ranked as a typo when the text wrote them apart, so catto still finds Cato first) | | cato xylophone | Cato | a word that matches nothing is set aside, not fatal | | jon smith | Jon Snow, John Smith | when no item has every word, the most words any item has are kept — whichever words they are |

The budget is the one Algolia and Typesense use. Meilisearch's (one typo from 5 letters, a first-letter typo costs two) cannot find a mistyped 4-letter name at all, and a 5–8-letter one whose first letter slipped.

Ranking — a tie-break, not a weighted sum

Each rule only decides between results the rules above it called equal: typos → words matched → field → exactness (the whole field typed, then whole words, then prefixes) → proximity → position in the field → your rank → shorter field. Sums need tuning and drift; a long description outscores a short exact title. This is Algolia's published order, adjusted for names.

For a search that runs in a database

The forgiving part does not need to live in the database. Keep the collection's words (createVocabulary), expand what was typed into the words it could mean, and send those along:

import { corrections, createVocabulary, didYouMean } from '@bitbaum/searchkit';

const vocab = createVocabulary(wordsWithCounts); // e.g. from a cached SQL view
corrections('cqto', vocab); // → ["cato"]: OR these into your tsquery / ILIKE
didYouMean('cqto', vocab); // → "cato", for the zero-result screen

Then rerank the rows the database returned with createSearch — one ranking for in-memory lists and database results alike.

Why not do it in Postgres: pg_trgm scores kanye/kayne at 0.2 and cqto/cato at 0.4, under any useful threshold, and fuzzystrmatch's levenshtein counts a swap as two edits. Use trigrams for recall at a low threshold if you like; decide typos here.

Your own spelling rule — normalize

SearchSpec.normalize(word) runs on every word, both sides, after the standard folding. Heidi reads Swiss German as people type it: "ie" as "i", doubled letters as one, so gruzi mittenand finds "Grüezi mitenand". Keep it to spelling; meaning belongs elsewhere. expandQuery/corrections take the same function so a vocabulary agrees with the index. The typo budget stays the WRITTEN word's: the rule turns "vill" into "vil", and a slip in "vill" is still forgiven as in any four-letter word.

Rows a database already found — rerank

rerank(rows, spec, query) ranks them by what was typed and keeps the rows it cannot place (matched on a field you did not pass, or by stemming) after the ranked ones, in their original order. OrangeCat's /api/search ranks Postgres candidates this way.

Highlighting

hit.matches[field] holds ranges into the original text (a typo match marks the whole word; a prefix only what was typed). highlightQuery(text, q) marks words that start with a typed word, for text that was not searched. Both return plain parts — never HTML. snippet(text, ranges, { maxLength }) cuts a long description to the window around its first match, at word boundaries, with "…" where it was cut, and re-bases the ranges onto it.

React (@bitbaum/searchkit/react)

  • useSearchAsYouType(text, (q, signal) => fetch(...), { debounceMs: 100 }) — each keystroke aborts the request before it; a late answer to an older query is dropped.
  • <Highlight text ranges | query markClassName /> — unstyled <mark>s.

Proving it — typoCases and evaluate

const cases = typoCases(names); // every one-slip misspelling of every name
const report = await evaluate(cases, q => search(q).map(h => h.id));
// { successAt1, successAt3, mrr, zeroResults, misses }

Slips are generated, not imagined: a neighbouring key (QWERTY and QWERTZ), a dropped letter, a doubled letter, a swapped pair. Put it in CI and "one slip still finds it" fails the day it stops being true. This package's own suite runs 571 such cases over eight names at success@1 = 1.

What it does not do

  • Meaning. "someone to fix my bike" finding "Velo repair" is embeddings' job (descriptive queries, never names — dense retrieval loses to lexical on names by tens of points). Fuse such results with Reciprocal Rank Fusion (k = 60) if you add them.
  • Security. Whether a row may be shown is decided before it reaches this package. An in-memory matcher has no business behind a visibility gate.
  • Phonetics. Schmidt/Schmitt, Meier/Mayer. Useful for person names only, harmful elsewhere; not yet built.

Sources

Algolia typo tolerance and ranking criteria; Typesense search parameters; Meilisearch typo internals; Damerau (1964), Pollock & Zamora (1983) on single edits; Schulz & Mihov (2002) on Levenshtein automata; SymSpell; Nielsen's response-time limits; Cormack, Clarke & Büttcher (SIGIR 2009) on RRF; Sciavolino et al. (EMNLP 2021) on dense retrieval and entity names.