jaspr_search 0.2.0
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@clientcomponent: a trigger button plus a modal dialog. Opens on click,⌘K/Ctrl+K, or/. Arrow keys move the selection,Enternavigates,Esccloses. Ships with default styles that respectjaspr_content's theme colors; override or opt out. One line, no client entrypoint of your own.SearchDialogView— the same dialog with no@clientannotation, 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@clientcomponent. Why the split.jaspr_searchCLI /buildSearchIndex— walks acontent/directory of markdown files, splits each page on its headings, and produces the doc list that becomessearch-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.