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.

Requirements

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

Installation

@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.

From npm

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.

From a CDN

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

From source

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.

Quick Start

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.

Fields

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.

Examples & Notes

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.

Format

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:

shaper.format = (fields) =>
  fields.map((f) => `${f.field ?? 'q'}=${f.value}`).join(';')

Actions & Destination

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)
})

History

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.

Styling

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.

Headless Mode & Events

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 ... />

      

Full Reference

Attributes / properties

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).

Events

See the table under Headless Mode & Events above.