Skip to content

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.

Note

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 flips state.searchReady (re-running any pending query).

  • runSearch(state, e) reads the input event, stores flat rows on state.searchResults , publishes the row count as state.searchCount , and resets state.searchActive . Wire it to your input's oninput .

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.

Tip

"$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.

Tip

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.

Note

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.