npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jxsuite/search

v0.2.6

Published

Static site search for Jx: build-time index emission from content collections and a headless MiniSearch browser client

Readme

@jxsuite/search

Full-text site search built at compile time from content collections. No server, no third-party service.

Overview

@jxsuite/search indexes a site's content collections during jx build and emits a single JSON index file next to the pages. In the browser, a headless MiniSearch client fetches that file once and answers queries synchronously. Two classes, declared in jx-extension.json:

  • SearchIndex: owner of the project.json search section. projectData normalizes the section into _project.search; emit (specs/extensions.md §8.4) builds the index from the already-loaded content collections and hands it to the host to write.
  • Search is a state class that lowers to core defs (specs/extensions.md §8.3): its static lower rewrites a timing: "client" def into a core $prototype: "Function" computed, so the only extension code reaching the browser is the client bundle.

The schema fragment schemas/project.fragment.schema.json ($id https://jxsuite.com/schema/ext/search/project/v1) is the source of truth for the section's shape and defaults: it requires collections with at least one entry, requires basePath per collection, and rejects unknown keys. engine is an enum whose only member today is "minisearch". Docs: /docs/framework/site/search (authoring) and /docs/extending/extensions/search (the extension itself).

Enable it

{
  "extensions": ["@jxsuite/parser", "@jxsuite/search"],
  "search": {
    "output": "/search-index.json",
    "collections": {
      "docs": {
        "basePath": "/docs/",
        "boost": { "title": 4, "heading": 2 }
      }
    }
  }
}

@jxsuite/parser is what loads the content collections this indexes. Defaults per collection: fields ["title", "heading", "text"], sections true, sectionDepth 3; output defaults to /search-index.json. basePath is normalized to leading and trailing slashes, so "blog" becomes "/blog/". This is the whole configuration surface. There is no CLI of its own.

One default does not come from the fragment: boost falls back to {} in normalizeSearchConfig (src/shared.ts), and the fragment declares no default for it. The schema is the source of truth for the section's shape and for every other default.

Build-time emission (SearchIndex)

emit reads ctx.sections.content (the collections the parser already loaded, keyed by name) and never re-reads source files. It returns exactly one record, { path: <output>, content }, which the compiler writes under outDir (step 6e of the site build). The content is one envelope:

{ "version": 1, "engine": "minisearch", "fields": [], "boost": {}, "documents": [] }

fields is the union of every configured collection's fields and boost their merged boost maps, baked in so the client needs no configuration of its own. Both cover only the collections that actually loaded: a name matching no content collection is skipped before its fields and boosts are merged, so a typo silently narrows the index rather than failing the build.

Each content entry yields a page document (heading: "", the entry's full extracted text, id <collection>:<entry-id>) and, when sections is on, one section document per heading, id <collection>:<entry-id>#<anchor> and URL carrying the same #anchor. title falls back to the entry id when frontmatter has no string title; description falls back to "". Entry URLs honour build.trailingSlash (default "always").

Localized collections are indexed per locale. Each entry is indexed at the URL its own locale routes to: under the default prefix-except-default routing the default locale stays unprefixed and only the others carry a /<locale> prefix, while under prefix-always every locale carries one. The document id becomes <collection>:<locale>:<entry-id>, and the document carries a canonical locale tag. A project with no i18n emits documents with no locale field at all.

The Search state class

{
  "state": {
    "q": "",
    "results": { "$prototype": "Search", "timing": "client", "query": { "$ref": "#/state/q" } }
  }
}

query takes a literal string or a $ref into page state (#/state/ui/q compiles to state.ui.q; a $ref outside #/state/ degrades to ""). Other parameters: limit (8), group (true), index (index-URL override), locale. The index URL resolves def indexsearch.output/search-index.json.

lower emits { $bundle: ["npm:@jxsuite/search/client"], $prototype: "Function", timing: "client", body }. The body lazy-imports the client once per page onto globalThis.__jxSearch, preloads the index, flips state.__jxSearchReady, and returns results only once isReady(). Reading that flag is what makes the computed re-run when the client finishes loading. The bundle URL is the deterministic, hash-free /assets/jxsuite-search-client.js, derived from sidecarAssetPath("npm:@jxsuite/search/client") in @jxsuite/schema/asset-paths, the same mapping the compiler's sidecar bundler uses, so neither side depends on the other.

The client (@jxsuite/search/client)

MiniSearch inside (fuzzy: 0.2, prefix: true), no UI. Core API: preload(indexUrl?), isReady(), and a synchronous query(q, { limit, group, pageCap, locale }) with defaults limit 8, group true, pageCap 3 (flat mode). That limit default governs a direct query() call only: the $src helper runSearch below always passes a fixed UI_LIMIT of 20. Grouped mode returns SearchResultGroup[]: a page with its matching sections, sorted by best score. Flat mode returns presentation-ready SearchResult[] carrying crumbs (title-cased slug ancestors, plus the page title on a section row), titleTokens and excerptTokens. Those two are arrays of { t, m } runs where m: true marks a match, so renderers highlight without injecting HTML.

Compiled components do not go through lowering, so inside a component you use the $src state conventions instead of the Search prototype (sites/jxsuite.com/components/site-search.json is the working example):

{
  "state": {
    "searchInit": {
      "$prototype": "Function",
      "$src": "npm:@jxsuite/search/client",
      "parameters": ["state"]
    },
    "runSearch": {
      "$prototype": "Function",
      "$src": "npm:@jxsuite/search/client",
      "parameters": ["state", "e"]
    }
  }
}

searchInit(state) returns true immediately, preloads (honouring state.searchIndexUrl), then sets state.searchReady and re-runs a pending state.searchQuery. runSearch(state, event?) reads the query from event.target.value (else state.searchQuery) and publishes state.searchResults, state.searchCount and state.searchActive. $src parameters bind by name, not position (spec.md §5.3): a parameter literally named state gets reactive state, any other name gets the event.

Surprises

  • A collection name that is not a loaded content collection warns and is skipped. The build still succeeds and the envelope still emits, possibly with zero documents. A typo is therefore a silently empty search box.
  • A heading with no id never starts a section document. Heading ids come from the parser (specs/parser.md §3.2) and are what make the #anchor land on the rendered page; a heading without one folds into the enclosing section, as does anything under a heading deeper than sectionDepth. Text before the first heading belongs to no section; the page document covers it.
  • emit only runs when the project declares a non-empty search section, because the compiler skips section-owner emitters whose value is null or {} (specs/extensions.md §8.4, "Gating").
  • The public/ copy runs after emit, so a file in public/ at the same path shadows the emitted index (the same semantics as sitemap.xml).
  • Omitting locale is not the same as locale: null. Omitted means the page's own language, read from <html lang>; null searches every language. Both lower and resolve drop the key rather than serialize undefined. A document with no locale answers every search, and region is ignored when tags differ only there (fr matches fr-CA).
  • Search.resolve() returns [] outside the browser, with a warning. Search is a client-side interaction, so compiler-timing bakes and dev-server resolve proxying degrade rather than fail.
  • The index is one file per site and the client is a module-level singleton, so the first preload wins. Multiple collections share the one index; results carry their collection name. A failed load clears the memo so a later call can retry.
  • First-party (@jxsuite/*) schema fragments are read from the host, not the project's node_modules: schema composition routes every @jxsuite/* .json ref through host resolution and throws when the host cannot resolve it, so installing this package into a project alone does not make its search section validate. A third-party extension's fragment still resolves project-first.
  • Class names are global across extensions. Search and SearchIndex occupy the $prototype-visible namespace, not a package-scoped one.
  • bun run build bundles only search-index.ts and search-state.ts into dist/. The client is deliberately not built here, because the site build bundles it from source through the sidecar bundler.

Versioning

Published to npm as @jxsuite/search, TypeScript source like every @jxsuite package, following the monorepo's release train. Its runtime dependencies are @jxsuite/schema and minisearch; extensions may depend on core packages but never the reverse: no core package lists this one as a runtime dependency or imports it from src/, and scripts/check-dep-rules.ts enforces that.