@molecule/app-client-search
v1.0.4
Published
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
Downloads
929
Maintainers
Readme
@molecule/app-client-search
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
{@link createIndex} anywhere.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
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 modeType
core
Installation
npm install @molecule/app-client-search @molecule/app-bondAPI
Interfaces
ParseQueryOptions
Options for {@link 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[]
}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
}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 {@link 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
}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
}ClientSearchQuery
A query in its structured form. {@link 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
}ParsedQuery
What {@link 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[]
}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 {@link parseQuery} first (so `category:auth oauth`
* works). A {@link 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
}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>
}Types
ClientSearchDocument
Any record with string keys. Field values may be strings, numbers, booleans or arrays of those.
type ClientSearchDocument = Record<string, unknown>Functions
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): voidprovider— The client search provider implementation to bond.
getProvider()
Retrieves the bonded client search provider, throwing if none is configured.
function getProvider(): ClientSearchProviderReturns: The bonded client search provider.
hasProvider()
Checks whether a client search provider is currently bonded.
function hasProvider(): booleanReturns: true if a client search provider is bonded.
createIndex(docs, options)
Builds an index using the bonded provider.
function createIndex<T extends ClientSearchDocument>(
docs: T[],
options: ClientSearchIndexOptions<T>,
): ClientSearchIndex<T>docs— The documents to index.options— How to index them.
Returns: A searchable index.
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): ParsedQueryraw— The text typed.options— Which fields are filterable.
Returns: The structured query.
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): strings— A token or value.
Returns: The text without its quotes.
filterFieldsOf(options)
The fields a query may filter on for an index: filterFields when set,
otherwise the searched fields.
function filterFieldsOf<T extends ClientSearchDocument>(
options: ClientSearchIndexOptions<T>,
): string[]options— The index options.
Returns: The filterable field names.
extractFieldText(doc, field)
The default field extractor: arrays join with spaces, everything else is
stringified, nothing becomes ''.
function extractFieldText(doc: ClientSearchDocument, field: string): stringdoc— The document.field— The field name.
Returns: Searchable text.
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): booleandoc— 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[]): booleandoc— 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,
): booleandoc— The document.fields— The searched fields.phrases— Required phrases.exclude— Forbidden terms or phrases.extract— The field extractor.
Returns: true when the document passes.
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'>): stringparsed— The structured query.
Returns: Text that {@link parseQuery} reads back to the same structure.
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.
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-bondThis 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.
