@molecule/app-client-search

Core interface · client-search · App (browser) · v1.0.2 · Apache-2.0

Client-side search core interface for molecule.dev — index a list already in the browser, then search it with typo tolerance, field filters (`category:auth`), phrases and exclusions

npm install @molecule/app-client-search

npm · Source on GitHub

How it works

@molecule/app-client-search is the client-search core interface on the app (browser) side: the API your app calls, with no vendor inside.

Choose the implementation by bonding one of its 1 provider: @molecule/app-client-search-minisearch.

import { createIndex, setProvider } from '@molecule/app-client-search'
import { provider } from '@molecule/app-client-search-minisearch'

setProvider(provider)

const index = createIndex(packages, {
  idField: 'name',
  fields: ['name', 'description', 'exports'],
  filterFields: ['category', 'type', 'stack'],
  boost: { name: 3 },
})

index.search('category:auth oauth') // auth packages mentioning OAuth, best first
index.search('"sign in" -deprecated') // the phrase, minus anything deprecated
index.search({ text: '', filters: [{ field: 'type', values: ['bond'] }] }) // browse mode

Providers (1): @molecule/app-client-search-minisearch

Works with: @molecule/app-bond

Reference

Client-side search core interface for molecule.dev.

Index a list the app already holds in memory — a package catalog, a settings page, a table — and search it as the person types: prefix matching, typo tolerance, per-field boosts, and one query grammar every bond understands (category:auth -deprecated "sign in"). Bond a provider (e.g. @molecule/app-client-search-minisearch) at startup, then call createIndex anywhere.

Quick Start

import { createIndex, setProvider } from '@molecule/app-client-search'
import { provider } from '@molecule/app-client-search-minisearch'

setProvider(provider)

const index = createIndex(packages, {
  idField: 'name',
  fields: ['name', 'description', 'exports'],
  filterFields: ['category', 'type', 'stack'],
  boost: { name: 3 },
})

index.search('category:auth oauth') // auth packages mentioning OAuth, best first
index.search('"sign in" -deprecated') // the phrase, minus anything deprecated
index.search({ text: '', filters: [{ field: 'type', values: ['bond'] }] }) // browse mode

Type

core

Installation

npm install @molecule/app-client-search @molecule/app-bond

API

Interfaces

ClientSearchFilter

One field:value constraint. Values are OR'd; several filters are AND'd.

interface ClientSearchFilter {
  /** The field the constraint applies to. */
  field: string

  /** Accepted values, compared case-insensitively; a trailing `*` matches a prefix. */
  values: string[]

  /** Documents matching this filter are DROPPED instead of kept (`-field:value`). */
  negate?: boolean
}

ClientSearchHit

One matching document.

interface ClientSearchHit<T extends ClientSearchDocument = ClientSearchDocument> {
  /** The document's id (its `idField` value, stringified). */
  id: string

  /** Relevance; higher is better. Browse-mode results (empty text) score `0`. */
  score: number

  /** The document as it was indexed. */
  doc: T

  /** The query terms that matched (after prefix/fuzzy expansion). */
  terms: string[]

  /** The fields the terms matched in. */
  fields: string[]
}

ClientSearchIndex

An index over a set of documents. Built once, searched many times; documents may be added, replaced or removed afterwards.

interface ClientSearchIndex<T extends ClientSearchDocument = ClientSearchDocument> {
  /** The options the index was built with. */
  readonly options: ClientSearchIndexOptions<T>

  /** Number of documents in the index. */
  readonly size: number

  /**
   * Searches the index.
   *
   * A string is parsed with `parseQuery` first (so `category:auth oauth`
   * works). A `ClientSearchQuery` is used as given. Empty text with
   * filters is browse mode: every document the filters accept, in insertion
   * order, scored `0`. Empty text and no filters returns every document.
   */
  search(query: string | ClientSearchQuery): ClientSearchHit<T>[]

  /** Completions for a partial term, most likely first. */
  suggest(prefix: string, limit?: number): string[]

  /** Adds a document. Adding an id that exists is an error; use `replace`. */
  add(doc: T): void

  /** Replaces the document with the same id, or adds it. */
  replace(doc: T): void

  /** Removes a document by id. Unknown ids are ignored. */
  remove(id: string): void
}

ClientSearchIndexOptions

How an index is built.

interface ClientSearchIndexOptions<T extends ClientSearchDocument = ClientSearchDocument> {
  /** The field holding each document's unique id (stringified). */
  idField: string

  /** The fields full-text search reads. Array values are joined with spaces. */
  fields: string[]

  /**
   * Fields a query may filter on with `field:value` — see `parseQuery`.
   * Defaults to `fields`. A filter field need not be a searched field: a
   * `category` used only for filtering is a common case.
   */
  filterFields?: string[]

  /** Per-field weight multipliers, e.g. `{ name: 3, description: 1 }`. */
  boost?: Record<string, number>

  /** Match on term prefixes (`auth` finds `authentication`). Defaults to `true`. */
  prefix?: boolean

  /**
   * Typo tolerance as a fraction of the term length (`0.2` lets a 5-letter
   * term be off by one letter). `false` disables it. Defaults to `0.2`.
   */
  fuzzy?: number | false

  /**
   * Turns a field's value into searchable text. The default joins arrays with
   * spaces and stringifies everything else; `undefined`/`null` become `''`.
   */
  extractField?: (doc: T, field: string) => string
}

ClientSearchProvider

Client search provider interface. All client-search bonds implement this.

interface ClientSearchProvider {
  /** Provider name identifier (e.g. `'minisearch'`). */
  readonly name: string

  /**
   * Builds an index over `docs`.
   *
   * @param docs - The documents to index.
   * @param options - How to index them.
   * @returns A searchable index.
   */
  createIndex<T extends ClientSearchDocument>(
    docs: T[],
    options: ClientSearchIndexOptions<T>,
  ): ClientSearchIndex<T>
}

ClientSearchQuery

A query in its structured form. parseQuery produces one from the text a person types; the app may also build one directly.

interface ClientSearchQuery {
  /** The free-text part, with filters, phrases and exclusions already removed. */
  text: string

  /** `"quoted phrases"` that must appear verbatim (case-insensitive) in a searched field. */
  phrases?: string[]

  /** `-terms` that must NOT appear (as a word prefix) in any searched field. */
  exclude?: string[]

  /** `field:value` constraints. */
  filters?: ClientSearchFilter[]

  /** Maximum hits to return. Defaults to the bond's choice (all matches). */
  limit?: number
}

HighlightSegment

A piece of text, marked when it matched a query term.

interface HighlightSegment {
  /** The text of this piece. */
  text: string
  /** Whether it matched. */
  hit: boolean
}

ParsedQuery

What parseQuery returns: a query plus the text it came from.

interface ParsedQuery extends ClientSearchQuery {
  /** The original text, untouched. */
  raw: string

  phrases: string[]
  exclude: string[]
  filters: ClientSearchFilter[]
}

ParseQueryOptions

Options for parseQuery.

interface ParseQueryOptions {
  /**
   * Fields that `field:value` may target. A `word:thing` whose `word` is not
   * listed is kept as ordinary text (so a search for `re:act` still works).
   * When omitted, every `word:value` is treated as a filter.
   */
  filterFields?: string[]
}

Types

ClientSearchDocument

Any record with string keys. Field values may be strings, numbers, booleans or arrays of those.

type ClientSearchDocument = Record<string, unknown>

Functions

createIndex(docs, options)

Builds an index using the bonded provider.

function createIndex(docs: T[], options: ClientSearchIndexOptions<T>): ClientSearchIndex<T>
  • docs — The documents to index.
  • options — How to index them.

Returns: A searchable index.

extractFieldText(doc, field)

The default field extractor: arrays join with spaces, everything else is stringified, nothing becomes ''.

function extractFieldText(doc: ClientSearchDocument, field: string): string
  • doc — The document.
  • field — The field name.

Returns: Searchable text.

filterFieldsOf(options)

The fields a query may filter on for an index: filterFields when set, otherwise the searched fields.

function filterFieldsOf(options: ClientSearchIndexOptions<T>): string[]
  • options — The index options.

Returns: The filterable field names.

formatQuery(parsed)

Puts a parsed query back into text, for editing one part of it (removing a filter chip). Terms and phrases keep their order; exclusions and filters follow.

function formatQuery(parsed: Omit<ParsedQuery, 'raw'>): string
  • parsed — The structured query.

Returns: Text that parseQuery reads back to the same structure.

getProvider()

Retrieves the bonded client search provider, throwing if none is configured.

function getProvider(): ClientSearchProvider

Returns: The bonded client search provider.

hasProvider()

Checks whether a client search provider is currently bonded.

function hasProvider(): boolean

Returns: true if a client search provider is bonded.

highlightMatches(text, terms)

Splits text into plain and matched segments so a UI can mark what matched: each term matches at the start of a word, case-insensitively, longest terms first. No terms yields one plain segment.

function highlightMatches(text: string, terms: string[]): HighlightSegment[]
  • text — The text to mark.
  • terms — The matched terms (a hit's terms).

Returns: The segments, in order.

matchesFilter(doc, filter)

Whether a document passes a filter. Used by bonds so filter semantics are identical everywhere: a field's string value, or any element of an array value, equals one of the accepted values case-insensitively; a value ending in * matches a prefix instead.

function matchesFilter(doc: ClientSearchDocument, filter: ClientSearchFilter): boolean
  • doc — The document.
  • filter — The constraint.

Returns: true when the document passes.

matchesFilters(doc, filters)

Whether every filter accepts the document.

function matchesFilters(doc: ClientSearchDocument, filters: ClientSearchFilter[]): boolean
  • doc — The document.
  • filters — The constraints (AND'd).

Returns: true when all pass.

matchesText(doc, fields, phrases, exclude, extract)

Whether a document contains every phrase verbatim (case-insensitive) in at least one of the given fields, and none of the excluded terms as a word prefix in any of them.

function matchesText(
  doc: ClientSearchDocument,
  fields: string[],
  phrases: string[],
  exclude: string[],
  extract?: (doc: ClientSearchDocument, field: string) => string,
): boolean
  • doc — The document.
  • fields — The searched fields.
  • phrases — Required phrases.
  • exclude — Forbidden terms or phrases.
  • extract — The field extractor.

Returns: true when the document passes.

parseQuery(raw, options)

Parses search text into terms, phrases, exclusions and field filters.

Grammar (whitespace separates tokens):

  • word — a term; bonds match it by prefix and with typo tolerance.
  • "two words" — a phrase that must appear verbatim.
  • -word / -"two words" — must not appear.
  • field:value — only documents whose field is value (case-insensitive).
  • field:a,b — a OR b. field:"multi word" quotes a value.
  • field:val* — a value prefix.
  • -field:value — everything EXCEPT those documents.
function parseQuery(raw: string, options?: ParseQueryOptions): ParsedQuery
  • raw — The text typed.
  • options — Which fields are filterable.

Returns: The structured query.

setProvider(provider)

Registers a client search provider as the active singleton. Called by bond packages (e.g. @molecule/app-client-search-minisearch) during app startup.

function setProvider(provider: ClientSearchProvider): void
  • provider — The client search provider implementation to bond.

tokenize(raw)

Splits text into tokens, keeping quoted runs together (a "b c" d:"e f" → a, "b c", d:"e f").

function tokenize(raw: string): string[]
  • raw — The text.

Returns: Its tokens, quotes still attached.

unquote(s)

Strips one layer of double quotes, tolerating an unclosed quote.

function unquote(s: string): string
  • s — A token or value.

Returns: The text without its quotes.

Available Providers

ProviderPackage
Client Search@molecule/app-client-search-minisearch

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-bond ^1.0.1

Runtime Dependencies

  • @molecule/app-bond

  • This searches memory, not a server. Hand it the list you already rendered (a few thousand records is fine); for search over a database or millions of rows use @molecule/api-search on the API side.

  • Filter fields are opt-in. field:value only becomes a filter when field is in filterFields (or in fields when filterFields is unset); anything else stays ordinary text, so a query like re:act still searches. List the fields you want filterable, and keep the names short: they are what people type.

  • Filters compare whole values, not substrings. category:auth matches a category of auth or an array containing auth, case-insensitively; use category:auth* for a prefix. Free text is what matches inside descriptions.

  • Empty text is browse mode. With filters it lists every document they accept in insertion order (score 0); with nothing at all it lists every document. Use that for "show the category" instead of a second code path.

  • The index is plain data with methods — build it once per list (useMemo on the docs), not on every keystroke, and replace/remove documents in place when the list changes.

  • This core renders nothing and binds no key. A results list, highlighting and a / shortcut belong to the app (see @molecule/app-client-search-react and @molecule/app-keyboard-shortcuts), styled via getClassMap() and translated with t().

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Typing in the search box narrows the list on every keystroke, and the best match is first (a name match beats a description match).
  • A typo (paymnts) still finds the right records.
  • field:value narrows to that value, and combining it with text narrows further; an unknown word:thing searches as plain text.
  • A quoted phrase only matches records containing it verbatim, and a -term removes records containing that term.
  • Clearing the box restores the full list, with no stale results.