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

@laplace.live/facet

v0.0.4

Published

Tiny parser for Gmail-style faceted search queries

Readme

@laplace.live/facet

Tiny parser for Gmail-style faceted search queries. Turn strings like to:me -from:[email protected] "project alpha" foobar into structured conditions and free-text segments — and serialize them back to a string when you're done.

  • Zero runtime dependencies
  • ESM only, ships with TypeScript types
  • Conditions, negation, comma-grouping, single/double quoting, escapes
  • Round-trips: Facet.parse(s).toString() produces a normalized, equivalent query
  • Pluggable text transforms (e.g. #tags, @mentions, <email@host>)
  • Optional numeric comparator helper for ranges like >30, <=10, 10..50

Install

bun add @laplace.live/facet
npm install @laplace.live/facet
# or: pnpm add @laplace.live/facet
# or: yarn add @laplace.live/facet

This package is ESM only and requires a runtime that supports modern ECMAScript modules (Node 18+, Bun, Deno, modern browsers, or any bundler).

Quick start

import Facet from "@laplace.live/facet";

const query = Facet.parse('to:me -from:[email protected] "project alpha" foobar');

query.getParsedQuery();
// {
//   to: ['me'],
//   exclude: { from: ['[email protected]'] }
// }

query.getTextSegments();
// [
//   { text: 'project alpha', negated: false },
//   { text: 'foobar', negated: false }
// ]

query.toString();
// 'to:me -from:[email protected] project alpha foobar'

Query syntax

| Syntax | Meaning | | -------------------------- | ---------------------------------------------------------- | | keyword:value | A condition (filter) | | -keyword:value | A negated condition (excluded) | | keyword:a,b,c | Comma-separated values for the same keyword | | keyword:"a b" | Quoted operand — may contain spaces, commas, or colons | | keyword:'a b' | Single quotes work too | | keyword:"he said \"hi\"" | \" escapes a double quote inside a double-quoted operand | | foo bar | Bare words become text segments | | -foo | A negated text segment | | "foo bar" | A quoted text segment is preserved as one unit |

Notes:

  • Dashes inside a word (my-string) are treated as literal characters; only a leading - at the start of a token marks negation.
  • An unmatched trailing quote is treated as a literal character.
  • A dangling to: (no value) parses as a condition with an empty string value.

API

Facet.parse(input, transforms?)

Parse a query string. Returns a Facet instance.

import Facet from "@laplace.live/facet";

const facet = Facet.parse('from:[email protected],[email protected] to:me subject:"weekly sync"');

facet.getConditionArray();
// [
//   { keyword: 'from', value: '[email protected]',     negated: false },
//   { keyword: 'from', value: '[email protected]',     negated: false },
//   { keyword: 'to',   value: 'me',          negated: false },
//   { keyword: 'subject', value: 'weekly sync', negated: false }
// ]

The optional second argument is described in Custom text transforms.

getConditionArray(): Condition[]

The flat list of conditions in the order they appeared in the input.

Facet.parse("to:a to:b").getConditionArray();
// [
//   { keyword: 'to', value: 'a', negated: false },
//   { keyword: 'to', value: 'b', negated: false }
// ]

getParsedQuery(): ParsedQuery

A grouped view of conditions. Negated conditions are bucketed under a special exclude key.

const facet = Facet.parse("to:a -to:b to:c -to:d");
facet.getParsedQuery();
// {
//   to: ['a', 'c'],
//   exclude: { to: ['b', 'd'] }
// }

getTextSegments(): TextSegment[]

The bare text portions of the query, with per-segment negation preserved.

Facet.parse("hello -big -fat is:condition world").getTextSegments();
// [
//   { text: 'hello', negated: false },
//   { text: 'big',   negated: true },
//   { text: 'fat',   negated: true },
//   { text: 'world', negated: false }
// ]

getAllText(): string

All text segments joined by a single space, with - prepended to negated ones.

Facet.parse("hello -world").getAllText();
// 'hello -world'

addEntry(keyword, value, negated)

Append a new condition. Does not deduplicate against existing entries.

const facet = Facet.parse("to:me");
facet.addEntry("to", "you", false);
facet.addEntry("from", "spam", true);
facet.toString();
// 'to:me,you -from:spam'

removeEntry(keyword, value, negated)

Remove a single condition matching all three fields. If duplicates exist, only the first match is removed. No-op when nothing matches.

const facet = Facet.parse("foo:bar,baz");
facet.removeEntry("foo", "baz", false);
facet.getParsedQuery().foo; // ['bar']

removeKeyword(keyword, negated)

Remove every condition with the given keyword and negation flag. The negation flag matters — removeKeyword('to', false) will not touch -to:foo.

const facet = Facet.parse("op1:value op1:value2 -op3:value text");
facet.removeKeyword("op1", false);
facet.toString();
// '-op3:value text'

clone(): Facet

Returns an independent Facet instance. Mutations on the clone do not affect the original.

const original = Facet.parse("to:me foo");
const copy = original.clone();
copy.addEntry("from", "you", true);

original.toString(); // 'to:me foo'
copy.toString(); // 'to:me -from:you foo'

toString(): string

Serialize back to a normalized query string. Conditions with the same keyword and negation are grouped with commas, and operands containing spaces or commas are automatically quoted (with embedded " escaped).

Facet.parse("-to:[email protected] -to:[email protected] hello").toString();
// '-to:[email protected],[email protected] hello'

Facet.parse('subject:"weekly sync, q2"').toString();
// 'subject:"weekly sync, q2"'

The result is cached internally and recomputed automatically after any mutation (addEntry, removeEntry, removeKeyword).

Custom text transforms

Pass an array of transforms to Facet.parse to lift bare text into structured conditions. Each transform receives a text segment and may return { key, value } to convert it, or null / undefined to leave it alone.

import Facet from "@laplace.live/facet";

const tagTransform = (text: string) =>
  text.startsWith("#") ? { key: "tag", value: text.slice(1) } : null;

const mentionTransform = (text: string) =>
  text.startsWith("@") ? { key: "mention", value: text.slice(1) } : null;

const facet = Facet.parse("hello #urgent @alice", [
  tagTransform,
  mentionTransform,
]);

facet.getTextSegments(); // [{ text: 'hello', negated: false }]
facet.getParsedQuery().tag; // ['urgent']
facet.getParsedQuery().mention; // ['alice']

All transforms run on every text segment, so multiple lifters can coexist. Negation is preserved — if the source token was -#urgent, the resulting condition will be negated.

Numeric comparators

Facet.parse produces opaque keyword:value pairs — it has no built-in concept of numbers or ranges. The parseNumericComparator helper turns a comparison expression into a predicate you can apply to your own data.

Supported syntax (whitespace around operators is tolerated):

| Expression | Meaning | | ---------- | ------------------------------- | | 30 | Exact match (===) | | =30 | Exact match (explicit operator) | | >30 | Greater than | | >=30 | Greater than or equal | | <30 | Less than | | <=30 | Less than or equal | | 10..50 | Inclusive range [10, 50] |

Decimals (12.5) and negative numbers (-5, >-5, -10..10) are supported. Returns null for empty or unrecognized input so callers can ignore the condition gracefully (e.g. while a user is mid-typing) instead of treating it as "match nothing".

import Facet, { parseNumericComparator } from "@laplace.live/facet";

const facet = Facet.parse("price:>=30 price:<100 type:book");
const query = facet.getParsedQuery();

const predicates = (query.price as string[]).map(parseNumericComparator);

const items = [
  { title: "A", price: 25 },
  { title: "B", price: 30 },
  { title: "C", price: 99 },
  { title: "D", price: 150 },
];

const matches = items.filter((item) =>
  predicates.every((p) => p?.(item.price) ?? true),
);
// [{ title: 'B', price: 30 }, { title: 'C', price: 99 }]

Candidate handling is strict by design: undefined, NaN, and Infinity never match, so it's safe to feed missing fields directly into the predicate.

Utilities

getQuotePairMap(input)

Returns an index map of paired single and double quotes in a string, ignoring backslash-escaped quotes. Useful if you're building tooling on top of the same quoting rules Facet uses.

import { getQuotePairMap } from "@laplace.live/facet";

getQuotePairMap('a "real" end');
// { single: {}, double: { 2: true, 7: true } }

getQuotePairMap('a \\" "real" end');
// Escaped " at index 3 is ignored; the surrounding quotes still pair up.
// { single: {}, double: { 5: true, 10: true } }

TypeScript

All public types are re-exported from the package root.

import type {
  Condition,
  ParsedQuery,
  TextSegment,
  NumericComparator,
} from "@laplace.live/facet";
interface Condition {
  keyword: string;
  value: string;
  negated: boolean;
}

interface TextSegment {
  text: string;
  negated: boolean;
}

interface ParsedQuery {
  [key: string]: string[] | Record<string, string[]>;
  exclude: Record<string, string[]>;
}

type NumericComparator = (value: number | undefined) => boolean;

Development

This project uses Bun for installs, tests, and scripts, and tsdown for builds.

bun install        # install dependencies
bun test           # run the test suite
bun run check      # type-check with tsc --noEmit
bun run lint       # run Biome
bun run build      # build dist/index.mjs and dist/index.d.mts

Releases are managed with Changesets:

bunx changeset           # add a changeset describing your change
bun run version          # bump versions and update CHANGELOG (CI usually does this)
bun run release          # publish to npm (CI usually does this)

Prior art

This project is heavily inspired by mixmaxhq/search-string, which pioneered the Facet.parse / getConditionArray / getParsedQuery / toString API shape used here. @laplace.live/facet is a modernized take — ESM only, zero runtime dependencies, first-class TypeScript types, additional test coverage, and an optional numeric comparator helper — but the credit for the original design belongs to Mixmax.

License

MIT