@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-searchHow 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 modeProviders (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'sterms).
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 whosefieldisvalue(case-insensitive).field:a,b—aORb.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
| Provider | Package |
|---|---|
| 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-searchon the API side. -
Filter fields are opt-in.
field:valueonly becomes a filter whenfieldis infilterFields(or infieldswhenfilterFieldsis unset); anything else stays ordinary text, so a query likere:actstill searches. List the fields you want filterable, and keep the names short: they are what people type. -
Filters compare whole values, not substrings.
category:authmatches acategoryofauthor an array containingauth, case-insensitively; usecategory: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 (
useMemoon the docs), not on every keystroke, andreplace/removedocuments 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-reactand@molecule/app-keyboard-shortcuts), styled viagetClassMap()and translated witht().
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:valuenarrows to that value, and combining it with text narrows further; an unknownword:thingsearches as plain text. - A quoted phrase only matches records containing it verbatim, and a
-termremoves records containing that term. - Clearing the box restores the full list, with no stale results.