How to attach <query-shaper> to a search input on your own page. For the
design rationale behind these decisions, see this repo's SPEC.md,
CONTEXT.md, and docs/adr/ instead: this page is written for
integrators, not maintainers.
<query-shaper> only works in desktop Google Chrome 138+, with
adequate hardware resources
for the on-device model, served from a secure context: https:, or
localhost/127.0.0.1 for local development. Plain
http: on a real domain doesn't qualify, even on an otherwise-supported
browser. Everywhere else (an unsupported browser, an insecure context, or supported
Chrome on underpowered hardware), it's a no-op: the Target keeps working exactly like a
plain <input>/<textarea>, no Suggestions, no errors.
Treat it as a progressive enhancement, never a requirement for your search box to
function.
@internetarchive/query-shaper is published on npm. There's no UMD/global
build, but since jsdelivr and unpkg automatically mirror npm packages, a CDN URL for the
ES module already works with no build step of your own.
npm install @internetarchive/query-shaper
import '@internetarchive/query-shaper'
Importing the module is enough: it registers <query-shaper> as a
custom element as a side effect. There's nothing to call to initialize it. A plain
<script src="..."> without type="module" will not work,
since there's no non-module build.
<script
type="module"
src="https://cdn.jsdelivr.net/npm/@internetarchive/query-shaper/dist/query-shaper.js"
></script>
Or the equivalent unpkg URL:
https://unpkg.com/@internetarchive/query-shaper/dist/query-shaper.js. No
install step at all, at the cost of depending on a third party to keep serving it.
git clone <this repo>
cd query-shaper
npm install
npm run build # emits dist/query-shaper.js (ESM)
Copy dist/query-shaper.js (and its .d.ts if you want types) into
your own project and self-host it, then either:
<script type="module" src="/path/to/query-shaper.js"></script>
or, in a bundler-based project, import it by that same relative path.
Attach <query-shaper> to any <input>/<textarea>
by referencing its id via for:
<input id="search" type="search" />
<query-shaper for="search"></query-shaper>
That's the whole setup. On first focus (if the browser supports it), it establishes an
on-device model session. As the user types and pauses for a moment, Suggestions appear in
a built-in popup underneath the input; accepting one fills the input by default (see
Actions for other behaviors). With no fields declared,
Suggestions stay plain rewordings (typo fixes, finished-out phrases, broader related
terms), never a fielded reformulation, since nothing was declared to reformulate against.
Declare what fields your backend supports so Suggestions can reformulate into
field:value syntax, via the fields attribute or
.fields property. Two forms, pick whichever fits:
Inline JSON is an array of field descriptors:
<query-shaper
for="search"
fields='[{"name":"category"},{"name":"price","type":"number"},{"name":"in_stock","type":"boolean"},{"name":"language","description":"ISO 639-1 code, e.g. en, fr, ja"}]'
></query-shaper>
Free-form text is a human-written description:
<query-shaper
for="search"
fields="title, author, language:iso-639-1, date (allowed patterns YYYY[-MM[-DD]]), categories (comma-separated list)"
></query-shaper>
Each entry in the JSON array shape is:
{
name: string // required
type?: 'text' | 'number' | 'date' | 'boolean'
aliases?: string[]
description?: string
}
Without fields, the model is never told about any backend fields, so a
Suggestion that references one anyway is dropped rather than shown broken.
Two optional, declarative levers for steering generation without touching code,
especially useful once fields is involved, since the model can otherwise
revert to describing a fielded intent in prose instead of committing to real
field:value syntax.
examples is few-shot input/Suggestions pairs, an array of
{ input, suggestions } (plural, since one input can have more than one good
answer):
<query-shaper
for="search"
fields='[{"name":"author"},{"name":"title"},{"name":"first_publish_year","type":"number"}]'
examples='[
{"input":"harry potter books published before 2000","suggestions":["title:\"harry potter\" AND first_publish_year:[1000 TO 2000]"]},
{"input":"books by asimov but not in french","suggestions":["author:\"isaac asimov\" AND -language:fr"]},
{"input":"cheap-ish books about space","suggestions":["subject:space AND price:[0 TO 20]","affordable space books"]}
]'
></query-shaper>
Each entry in that array is:
{
input: string
suggestions: string[] // plural: one input can have more than one good answer
}
notes is free-form prose for domain-specific guidance that
doesn't fit the input/Suggestions pair shape (a business rule, a unit convention, a
disambiguation hint):
<query-shaper
for="search"
notes='A book or series title (e.g. "Harry Potter") always belongs in the title field, never author: author is only for the name of an actual person.'
></query-shaper>
examples teaches by demonstration, notes teaches by
instruction. Using both together is expected for a backend with real quirks. Both accept
JSON or a
free-form string, same fallback rule as fields.
The model always writes every Suggestion as Lucene-style text, regardless of
format: format only controls how
<query-shaper> re-renders that text for your specific backend:
format="lucene" (default): the model's own text, used verbatim, since it's
already meant to be Lucene syntax.
format="url-params": field/value pairs as URL query parameters. Add a
base attribute to compose a full URL instead of a bare query string.
format="simple-query-string": the classic
+required -excluded "exact phrase" style (Elasticsearch's
simple_query_string, MySQL boolean full-text mode).
.format property: a function over decomposed
{ field?, value, operator? } tuples, for shapes neither preset covers. Can
only be set imperatively (a function isn't expressible as an attribute):
shaper.format = (fields) =>
fields.map((f) => `${f.field ?? 'q'}=${f.value}`).join(';')
The action attribute controls what happens when a Suggestion is accepted:
| Value | Behavior |
|---|---|
fill (default) |
Fills the Target with the Suggestion's text. |
submit |
Fills the Target, then submits its <form>. |
opensearch |
Navigates via a template attribute holding a
{searchTerms}-style URL template.
|
output |
Fills the Target, then also writes the Suggestion's text to the
destination attribute's matched element(s) (a CSS selector; defaults
to a built-in <output>).
|
none |
Does nothing itself: query-shaper-accept still fires and History still
records, but the host handles everything else.
|
action is orthogonal to headless: headless controls
whether <query-shaper> renders its own popup at all, action
controls what accept() does once called (by either popup).
Want to call your own function when a Suggestion is accepted, rather than fill/submit/
navigate/write? action="none" plus a query-shaper-accept
listener already gives you exactly that: detail.suggestion is the precise
text of whichever Suggestion was accepted, not just notice that something was:
<query-shaper for="search" action="none"></query-shaper>
shaper.addEventListener('query-shaper-accept', (e) => {
myExistingFunction(e.detail.suggestion)
})
A bounded, recycling record of prior finalized queries, persisted in
localStorage and fed back to the model as few-shot context. Each entry pairs
the original Search Text with the Suggestion that was Accepted for it. This
pairing, not just the final text, is what lets the model infer intent from precedent.
max-history: cap on stored entries and how many feed into generation. Defaults to 10; 0 disables History entirely and clears any existing entries.history-key: overrides the localStorage partition key. Defaults to the Target's id; set this only when multiple instances should deliberately share one History.
The built-in popup renders in a Shadow DOM (mode: "open"), themed via CSS
custom properties that you set on query-shaper itself, or inherit from an
ancestor:
| Custom property | Default |
|---|---|
--query-shaper-background |
#fff |
--query-shaper-color |
#111 |
--query-shaper-border-color |
#ccc |
--query-shaper-font-family |
inherit |
--query-shaper-option-padding |
0.5em 0.75em |
--query-shaper-active-background |
#e0e0ff: the keyboard-active/hovered option |
For finer control, target individual elements from outside with
::part(): popup (the outer container),
listbox (the suggestion list), option (each suggestion),
output (the element action="output" writes to when no
destination is given), and
download-prompt/download-enable/download-dismiss and
downloading-notice for the download messages (suppressed entirely when
headless).
query-shaper {
--query-shaper-background: #1e1e2e;
--query-shaper-color: #eee;
--query-shaper-active-background: #3b3b58;
}
query-shaper::part(option) {
border-bottom: 1px solid #333;
}
For full visual control, skip all of this and go headless
instead: build the entire popup yourself from the events below.
Set the headless boolean attribute to render no popup UI at all (including
the built-in download-prompt messages) and drive your own UI entirely from events fired
on the <query-shaper> element itself:
| Event | detail |
Fires when |
|---|---|---|
query-shaper-generating |
{ searchText } |
A debounced generation call actually starts. |
query-shaper-suggestions |
{ suggestions } |
A new Suggestion set is ready (possibly empty). |
query-shaper-accept |
{ suggestion, action, url } |
A Suggestion was Accepted. |
query-shaper-status |
{ status } |
Model/session lifecycle transition. |
query-shaper-error |
{ error, phase } |
A generation call failed. |
For a "searching" indicator, you don't need to correlate specific generations: a
superseded call's outcome is always silently discarded internally, so it's safe to just
show the indicator on any query-shaper-generating and hide it on any
query-shaper-suggestions/query-shaper-error that follows. If your
own input can go back to empty, also clear your rendered results immediately on your own
input listener rather than waiting for the library's. Clearing is
debounced same as everything else, so there'd otherwise be a brief lag.
Since headless suppresses the built-in "Enable"/"downloading" messages too, a
headless host needs its own UI for the downloadable/downloading
statuses, and a way to actually trigger the download, which is what the public
download() method is for:
if (status === 'downloadable') {
// render your own "Enable" button, then on click:
shaper.download()
}
The floating search box at the top of this page is itself exactly this kind of headless
instance: its Suggestions are section ids (section:fields, etc.) that this
page's own script turns into links, rather than search-backend syntax. Expand its markup
below:
<query-shaper for="docs-search-input" headless ... />| Name | Default | Description |
|---|---|---|
for |
— | Required. The Target's id. |
fields / .fields |
unset | JSON array of field descriptors, or a free-form description string. |
examples / .examples |
unset | JSON array of { input, suggestions }, or a free-form string. |
notes / .notes |
unset | Free-form prose primed alongside Fields/Examples. |
format / .format |
lucene |
lucene | url-params | simple-query-string, or a custom render function (property only). |
base |
current URL, query/fragment stripped | Root URL format="url-params" optionally composes a full URL onto. |
action |
fill |
fill | submit | opensearch | output | none. |
template |
— | Required for action="opensearch". A {searchTerms} URL template. |
destination |
a built-in <output> |
CSS selector for action="output"'s write target(s). |
max-suggestions |
5 | Cap on returned Suggestions. |
max-history |
10 | Cap on stored/fed-back History entries. 0 disables and clears History. |
history-key |
the Target's id |
Overrides the localStorage partition key for History. |
headless |
off | Renders no popup UI; only emits events. |
.download() |
— | Triggers the model download when status is downloadable (for headless hosts, which never get the built-in button). |
See the table under Headless Mode & Events above.