The Twig API

{% caffeine %} runs one search and exposes it. {% caffeineresults %} marks the part that changes.

The tags

{% caffeine 'products' as search %}

  {{ search.searchBox({ placeholder: 'Search products' }) }}
  {{ search.currentRefinements() }}
  {{ search.refinementList('brand', { limit: 10 }) }}
  {{ search.rangeInput('price') }}
  {{ search.sortBy(['relevance', 'price_asc']) }}

  {% caffeineresults %}
    {{ search.stats() }}

    {% for hit in search.hits %}
      {% include '_cards/product' with { hit: hit } only %}
    {% endfor %}

    {{ search.pagination() }}
  {% endcaffeineresults %}

{% endcaffeine %}

as search is optional and defaults to search. Options go in a with hash:

OptionDefaultWhat it does
prefix''Namespaces every query parameter, so two indexes on one page do not collide.
pathcurrent URLWhat the facet links point back at.
tagdivWrapper element. false omits it — and with it, the runtime.
class, idPut on the wrapper.
runtimetruefalse leaves the JavaScript off entirely; the links still work.
htmxfalseEmit HTMX attributes instead of loading the runtime.

There is also a function, for when a search belongs in a {% set %} rather than wrapped around half a template:

{% set search = caffeine('products') %}

Results

search.hitsThe page of results. Each is the record’s payload plus objectID.
search.nbHitsTotal matching records.
search.page / search.nbPagesZero-based.
search.from / search.toOne-based positions, for “showing 25–48 of 137”.
search.queryThe text query.
search.isEmptyWhether there were no results.
search.processingTimeMSHow long the engine took.

Facets

{% for bucket in search.facet('brand').buckets %}
  <a href="{{ bucket.url }}" class="{{ bucket.isRefined ? 'is-on' }}">
    {{ bucket.label }} ({{ bucket.count }})
  </a>
{% endfor %}

A bucket carries value (the real, typed value — a boolean facet gives true, not "true"), key (its string projection, as it appears in a URL), label, count, isRefined and url.

Dates are formatted, booleans become Yes/No, and a hierarchical path is trimmed to its leaf — a menu already shows the ancestors as the path the visitor took to get there.

search.refinements is every active refinement flattened, each with the URL that removes it. search.hasRefinements says whether there are any.

URLs

Each returns the URL of the state that would result — never one that needs JavaScript to mean anything. All of them reset to the first page, because a visitor who narrows from 200 results to 3 while on page 7 should not be shown nothing.

{{ search.toggleUrl('brand', 'Acme') }}
{{ search.rangeUrl('price', 10, 50) }}          {# omit both to clear it #}
{{ search.geoUrl('near', lat, lng, 8000) }}     {# metres #}
{{ search.sortUrl('price_asc') }}
{{ search.pageUrl(2) }}
{{ search.queryUrl('cordless') }}
{{ search.clearUrl('brand') }}                  {# omit to clear everything #}
{{ search.canonicalUrl }}                       {# the unrefined URL #}

The URL format

State travels in a query string built to be read:

?q=cordless&brand=Acme,Globex&price=10..50&sort=price_asc&page=2
  • Values are comma-separated. A literal comma or backslash in a value is backslash-escaped.
  • Ranges use .., open at either end.
  • page is one-based.
  • Anything at its default is omitted, so the unrefined state is a bare URL.

One exception, on the way in only: a plain HTML form posts one value per field and cannot produce price=10..50, so price_min and price_max are also accepted when parsing. Nothing ever encodes them, so the next click restores the canonical URL. That is what lets the range widget work without JavaScript.

Events

The wrapper dispatches caffeine:ready when an instance is wired and caffeine:render after a refinement is swapped in, both bubbling. It carries data-caffeine-busy while a request is in flight, and data-caffeine-hydrating while a cached page is catching up to a refined URL.