jaspr_search 0.2.0 copy "jaspr_search: ^0.2.0" to clipboard
jaspr_search: ^0.2.0 copied to clipboard

Client-side full-text search for static Jaspr sites — a search dialog component plus a build-time indexer, with no backend to query.

jaspr_search #

Client-side full-text search for static Jaspr sites built with jaspr_content — a search dialog component plus a build-time indexer. No backend, no crawler, no API key: the index is a JSON file your site already ships, fetched once and scored in the browser.

Used by the Zonai and Revali documentation sites.

What you get #

  • SearchDialog — a @client component: a trigger button plus a modal dialog. Opens on click, ⌘K/Ctrl+K, or /. Arrow keys move the selection, Enter navigates, Esc closes. Ships with default styles that respect jaspr_content's theme colors; override or opt out. One line, no client entrypoint of your own.
  • SearchDialogView — the same dialog with no @client annotation, which is what lets it take components and callbacks: a custom trigger, empty state, "no matches" block and result row. Build it inside your own @client component. Why the split.
  • jaspr_search CLI / buildSearchIndex — walks a content/ directory of markdown files, splits each page on its headings, and produces the doc list that becomes search-index.json.
  • searchIndex — the ranking function: every query word must match somewhere in a section (AND, not OR), with score boosts for title/heading/URL matches and whole-phrase hits.

Usage #

1. Build the index #

For a site with no per-page grouping, the bundled CLI is enough:

dart run jaspr_search --content content --output web/search-index.json

Both flags default to those paths, so dart run jaspr_search alone works for the common layout. Add --check for CI, so a content change that forgot to regenerate the index fails the build instead of silently going stale:

dart run jaspr_search --check

Run dart run jaspr_search --help for the rest of the flags (--max-section-length among them).

If your site groups pages under sections and wants that label shown on each result, the CLI can't express it — groupFor is a Dart closure reaching into the site's own navigation code, not something a flag can carry. Write a tiny entrypoint instead:

// tool/build_search_index.dart
import 'dart:io';
import 'package:jaspr_search/builder.dart';

void main(List<String> args) async {
  final build = await buildSearchIndex(
    Directory('content'),
    groupFor: (route) => sectionOf(route)?.title ?? '',
  );
  final output = File('web/search-index.json');

  if (args.contains('--check')) {
    if (!searchIndexIsCurrent(build, output)) {
      stderr.writeln('web/search-index.json is stale. Run: dart run tool/build_search_index.dart');
      exit(1);
    }
    stdout.writeln('Search index is up to date (${build.docs.length} pages).');
    return;
  }

  writeSearchIndex(build, output);
  stdout.writeln('Wrote ${output.path} — ${build.docs.length} pages, ${build.sectionCount} sections.');
}

See the doc comment on buildSearchIndex for the rest of the hooks (routeOf, titleFor, descriptionFor, compare).

Either way, run it before jaspr build / jaspr serve.

2. Render the dialog #

import 'package:jaspr_search/jaspr_search.dart';

// wherever your header lives
const SearchDialog()

Customize text, the index path, or the result caps through its constructor — see the doc comment on SearchDialog for the full list.

3. Customize it #

Most changes need no Dart. Every part carries a stable class, and the status panel carries a data-state attribute, so CSS alone gets you a long way — either layered over the bundled styles or with includeDefaultStyles: false and nothing but your own:

Selector What it is
.jaspr-search-trigger (-label, -keys) the button in your header
.jaspr-search-dialog, ::backdrop the modal and its backdrop
.jaspr-search-panel, .jaspr-search-field the floating card and its input row
.jaspr-search-dismiss the "Esc" button
.jaspr-search-results the scrolling results area
.jaspr-search-empty[data-state] status panel; data-state is loading, idle, no-results or error
.jaspr-search-hint the hint under "No matches"
.jaspr-search-hit-list, .jaspr-search-hit-item the <ul> and each <li>
.jaspr-search-hit[data-selected] one result row; data-selected marks the keyboard selection
.jaspr-search-hit-crumb (-sep), -heading, -snippet the three lines of a row
.jaspr-search-hit mark a highlighted query match
.jaspr-search-footer, kbd the keyboard hints
.jaspr-search-icon the magnifier <svg>

For structural changes — a trigger that matches your own header's buttons, a translated empty state, a result row with your own layout — use SearchDialogView from inside your own @client component:

import 'package:jaspr/dom.dart'; // button, div
import 'package:jaspr/jaspr.dart';
import 'package:jaspr_search/jaspr_search.dart';

@client
class DocsSearch extends StatelessComponent {
  const DocsSearch({super.key});

  @override
  Component build(BuildContext context) => SearchDialogView(
    trigger: (open, shortcut) =>
        button(onClick: open, [Component.text('Search $shortcut')]),
    emptyState: (pageCount) => Component.text('Search $pageCount pages.'),
    noResults: (query) => Component.text('Nothing for “$query”.'),
    resultContent: (hit, tokens) =>
        div(classes: 'my-row', highlightQuery(hit.title, tokens)),
    footer: div([Component.text('↑↓ navigate · ↵ open · esc close')]),
  );
}

Then render const DocsSearch() where you would have rendered const SearchDialog(). SearchDialogView accepts everything SearchDialog does, plus those five hooks; highlightQuery is exported so a custom row can keep the same <mark> highlighting the default rows have.

Why there are two components #

jaspr_builder serializes every constructor parameter of an @client component: it encodes them into the pre-rendered HTML on the server and decodes them again in the browser. So an annotated component can only accept things that survive a JSON round trip — String, bool, int, num, double, List/Map of those, and types carrying @encoder/@decoder. A Component, a builder function or a callback is rejected, and the failure is not local: dart run build_runner build fails for the whole app, because that builder runs with auto_apply: all_packages and walks the entire dependency graph.

That builder only inspects classes that carry the annotation. SearchDialog carries it and is therefore primitives-only. SearchDialogView does not, so it can take whatever it likes — and by the time your @client wrapper builds one, you are already on the client, where nothing needs serializing. Your wrapper's own parameters are subject to the same primitives-only rule.

If you see this error, a @client component somewhere in your app — possibly yours — has a non-serializable parameter, and the fix is the split above:

@client components only support parameters of primitive serializable types
or types that define @decoder and @encoder methods.

Why client-side #

A statically generated site has no backend to query, and a hosted search service means a crawler, an API key in your config, and results that lag a deploy until the crawler catches up. Scoring a JSON index in the browser needs neither — the index is fetched once, on first use, so it costs nothing for readers who never search.

4
likes
160
points
180
downloads

Documentation

API reference

Publisher

verified publishermrgnhnt.com

Weekly Downloads

Client-side full-text search for static Jaspr sites — a search dialog component plus a build-time indexer, with no backend to query.

Repository (GitHub)
View/report issues

Topics

#jaspr #search #static-site #documentation

License

MIT (license)

Dependencies

args, jaspr, jaspr_content, universal_web

More

Packages that depend on jaspr_search