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, ~21 kB minified) answers queries entirely in the browser. No server and 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.
With
sections
on, the page document carries only the text
before
the first heading. The sections cover the rest, and storing both put the entire corpus in the index twice, doubling the download and the work the browser does to index it. Nothing becomes unsearchable: a body-text match now surfaces the section that contains it, deep-linked, instead of competing with it. An entry with no headings keeps its full text, because nothing else would index it.
Set
sections: false
for a collection whose entries are short enough that a deep link adds nothing; the page documents then carry their full text again.
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, so 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),
locale
(see below).
On a multilingual site
A collection kept
one directory per locale
is indexed once per language, and the search box
searches the page's own language
with no configuration, because it reads
<html lang>
, which the build wrote from the route's locale. Results link into that language's URL space.
Set
locale
to override it: a tag to search one named language, or
null
to search every one.
Without that default, a reader searching a French page would be handed the English copy of the page they're already on, ranked first because it matched the same words.
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, where the export names double as state keys:
"state": {
"searchQuery": "",
"searchResults": [],
"searchReady": false,
"searchActive": 0,
"searchInit": { "$prototype": "Function", "$src": "npm:@jxsuite/search/client", "$lazy": true, "parameters": ["state"] },
"runSearch": { "$prototype": "Function", "$src": "npm:@jxsuite/search/client", "$lazy": true, "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.
"$lazy": true
is doing real work here. Without it the client is a static import, so every page that renders the component downloads, parses and evaluates MiniSearch whether or not anybody searches. With it, all three happen on the first call.
Call
searchInit
when search is
opened
, not on mount. Warming the index ahead of time sounds free and is not: fetching and indexing a whole corpus is the single most expensive thing the page does, and doing it during load competes with the paint. The client handles being queried before it is ready (
query()
returns no rows and starts the load, and
searchInit
re-runs the pending query when it finishes), so booting on open costs the visitor nothing they notice.
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.