Site search
Jx sites get full-text search from the
@jxsuite/search
extension: the build emits a JSON index from your content collections, and a small headless client (MiniSearch under the hood, ~7 kB) answers queries entirely in the browser. No server, no third-party service — results work on any static host, and section matches deep-link straight to the heading (
/docs/framework/site/#assets
).
Enabling the section
Add the extension and a
search
section to
project.json
, then regenerate schemas with
jx schema
:
{
"extensions": ["@jxsuite/parser", "@jxsuite/search"],
"search": {
"collections": {
"docs": { "basePath": "/docs/", "boost": { "title": 4, "heading": 2 } }
}
}
}
Per collection:
| Key | Default | Meaning |
|---|---|---|
basePath |
(required) | URL prefix mapping entry ids to routes |
fields |
["title", "heading", "text"] |
Document fields the index searches |
boost |
{} |
Per-field score boosts |
sections |
true |
Also index one document per heading, with
#anchor
deep links |
sectionDepth |
3 |
Deepest heading level that gets its own section document |
Top-level:
output
(default
/search-index.json
) sets where the index is written;
engine
is
minisearch
(the only engine today — the field exists so future engines slot in without reshaping the section).
bunx jx build
then emits the index into
dist/
alongside your pages. The index holds two kinds of documents per entry: the whole page, and one per heading section — so a query can land on "the
Assets
section of
Site architecture
" rather than just the page.
Querying from page state
The
Search
prototype gives any page reactive results with zero client wiring:
"state": {
"q": "",
"results": { "$prototype": "Search", "query": { "$ref": "#/state/q" }, "limit": 8 }
}
Bind an input to
state.q
and map over
state.results
. Each result group is a page with its matching sections:
{
"slug": "framework/site",
"title": "Site architecture",
"url": "/docs/framework/site/",
"score": 12.4,
"hits": [{ "heading": "Assets", "url": "/docs/framework/site/#assets", "score": 9.1 }]
}
In compiled sites the def lowers to plain client code that lazily loads the bundled client and fetches the index on first use — nothing is downloaded until the visitor actually searches. Options:
limit
(max rows, default 8),
group
(set
false
for the flat row list described below),
index
(override the index URL).
Building a search UI component
Interactive components aren't lowered, so inside a compiled component you use the headless client directly through its
$src
state conventions — the export names double as state keys:
"state": {
"searchQuery": "",
"searchResults": [],
"searchReady": false,
"searchActive": 0,
"searchInit": { "$prototype": "Function", "$src": "npm:@jxsuite/search/client", "parameters": ["state"] },
"runSearch": { "$prototype": "Function", "$src": "npm:@jxsuite/search/client", "parameters": ["state", "e"] },
"onMount": { "$prototype": "Function", "arguments": ["state"], "body": "state.searchInit(state);" }
}
searchInit(state)preloads the index and flipsstate.searchReady(re-running any pending query).runSearch(state, e)reads the input event, stores flat rows onstate.searchResults, publishes the row count asstate.searchCount, and resetsstate.searchActive— wire it to your input'soninput.
The build bundles
npm:@jxsuite/search/client
(MiniSearch included) into
/assets/
automatically — declare the dependency in your project's
package.json
and import it like any module.
For full control, import the core API from the same module:
preload(indexUrl?)
,
isReady()
, and the synchronous
query(text, { limit, group, pageCap })
.
Rendering a result row
Flat rows (
group: false
, what
runSearch
uses) arrive presentation-ready. Each row is one page
or
one of its heading sections, and carries a breadcrumb plus pre-highlighted text — so a component can render matches without doing any string work of its own:
{
"url": "/docs/framework/site/#assets",
"title": "Site architecture",
"heading": "Assets",
"crumbs": ["Framework", "Site architecture"],
"titleTokens": [{ "t": "Assets", "m": true }],
"excerptTokens": [
{ "t": "…referenced from a page are copied into ", "m": false },
{ "t": "assets", "m": true },
{ "t": "/ at build time…", "m": false }
],
"score": 9.1
}
A token's
m
flag marks a run that matched a query term — whole words, so a prefix search for
intro
highlights
introduction
. Map over the tokens and style the matched runs; nothing is injected as HTML:
{
"$prototype": "Array",
"items": { "$ref": "$map/item/titleTokens" },
"map": {
"tagName": "span",
"attributes": { "class": },
"textContent":
}
}
crumbs
is the slug's ancestor trail, with the page title appended on a section row.
excerptTokens
cover a ~160-character window around the first body match, elided with
…
when it doesn't reach an edge. At most
pageCap
rows (default 3) come from any one page, so a long page can't flood the list.
Grouped results (
group: true
, the default and what the
Search
prototype returns) keep the older nested shape — a page with a
hits
array of its matching sections. Use flat rows for a search palette, grouped for a results page organized by document.
One index per site is the assumption: the first
preload
wins the in-page singleton. Multiple collections are fine — they share the one index and results carry their
collection
name.