From 60279824b5cb8cfb4a5486e54e6e3f200d9d5346 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Wed, 17 Jun 2026 16:58:35 +0100 Subject: [PATCH 1/4] DOC-1878: add "Exact match" toggle to docs search Add an exact-match option to the autocomplete dropdown (src/partials/algolia-script.hbs) and the /search page (src/layouts/search.hbs). When enabled, queries require all terms with exact spelling and no optional-word fallback (typoTolerance:false, removeWordsIfNoResults:'none', optionalWords:[]). Implemented via strict params rather than a quoted phrase: the index's searchable attributes use unordered(...), which disables phrase/proximity matching, so quoted phrases match nothing. On /search the params are applied through a search-client wrapper so the input keeps showing what the user typed. Co-Authored-By: Claude Opus 4.8 --- src/layouts/search.hbs | 39 +++++++++++++++++++++++++++++- src/partials/algolia-script.hbs | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/layouts/search.hbs b/src/layouts/search.hbs index 79c8b0ce..cd5dbfb1 100644 --- a/src/layouts/search.hbs +++ b/src/layouts/search.hbs @@ -15,6 +15,9 @@ {{/with}}
+
Filters
@@ -47,7 +50,32 @@ const { algoliasearch, instantsearch } = window; const { searchBox } = instantsearch.widgets; - const searchClient = algoliasearch('{{{env.ALGOLIA_APP_ID}}}', '{{{env.ALGOLIA_API_KEY}}}'); + // "Exact match" toggle state. When on, we require every term, exact spelling, + // and no optional-word fallback. We do this in a search-client wrapper so the + // search box keeps showing exactly what the user typed. NOTE: we deliberately + // do NOT wrap the query in quotes for a phrase match — the index's searchable + // attributes use unordered(...), which disables phrase/proximity matching, so + // a quoted phrase returns nothing; strict params give reliable exact matching. + let exactMatch = false; + + const baseClient = algoliasearch('{{{env.ALGOLIA_APP_ID}}}', '{{{env.ALGOLIA_API_KEY}}}'); + const searchClient = { + ...baseClient, + search(requests) { + if (exactMatch) { + requests = requests.map((request) => { + const params = { ...(request.params || {}) }; + if (params.query) { + params.typoTolerance = false; + params.removeWordsIfNoResults = 'none'; + params.optionalWords = []; + } + return { ...request, params }; + }); + } + return baseClient.search(requests); + }, + }; const indexName = '{{{env.ALGOLIA_INDEX_NAME}}}'; const search = instantsearch({ @@ -197,6 +225,15 @@ ]); search.start(); + + // Wire the "Exact match" checkbox: flip the flag and re-run the search. + const exactMatchCheckbox = document.getElementById('exact-match'); + if (exactMatchCheckbox) { + exactMatchCheckbox.addEventListener('change', (e) => { + exactMatch = e.target.checked; + if (search.helper) search.helper.search(); + }); + } } document.addEventListener('DOMContentLoaded', function(event) { diff --git a/src/partials/algolia-script.hbs b/src/partials/algolia-script.hbs index fed35a8a..e3044d58 100644 --- a/src/partials/algolia-script.hbs +++ b/src/partials/algolia-script.hbs @@ -133,6 +133,21 @@ window.__algoliaToggleFilter = function(label, checked) { } }; +/** + * Global toggle for "exact match" search. When on, the docs query is sent as a + * quoted phrase (advancedSyntax) with typo tolerance and optional words disabled, + * so results must contain the exact terms, in order. Exposed on `window` so the + * checkbox handler in the render template (re-created each render) calls a stable + * function. Same rationale as window.__algoliaToggleFilter above. + */ +window.__algoliaExactMatch = false; +window.__algoliaToggleExactMatch = function(checked) { + window.__algoliaExactMatch = !!checked; + if (window.__algoliaAutocompleteInstance) { + window.__algoliaAutocompleteInstance.refresh(); + } +}; + /** Stable refs + listeners for outside-click / Esc handling on the dropdown. */ const __dropdownRefs = { menu: null, toggle: null }; let __dropdownOutsideHandler = null; @@ -508,10 +523,26 @@ function initAlgolia() { ` : ''; + // "Exact match" toggle: forces a quoted-phrase, no-typo query (see getItems). + const exactToggle = html` + `; + // Layout: header (filters/AI), results list, right-side preview, footer tips. render( html`
${dropdown} + ${exactToggle} ${askAIButton}
@@ -709,6 +740,16 @@ function initAlgolia() { { sourceId: 'docs', getItems() { + // When "Exact match" is on, require every term, exact spelling, and no + // optional-word fallback. NOTE: we deliberately do NOT wrap the query in + // quotes for a phrase match — the index's searchable attributes use + // unordered(...), which disables phrase/proximity matching, so a quoted + // phrase returns nothing. Strict params give reliable exact-term matching. + // See window.__algoliaToggleExactMatch. + const exactMatch = !!window.__algoliaExactMatch; + const exactParams = exactMatch + ? { typoTolerance: false, removeWordsIfNoResults: 'none', optionalWords: [] } + : {}; // getAlgoliaResults: https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/sources/#param-getalgoliaresults return getAlgoliaResults({ searchClient, @@ -722,6 +763,7 @@ function initAlgolia() { attributesToSnippet: ['*:25'], // https://www.algolia.com/doc/api-reference/api-parameters/attributesToSnippet/ snippetEllipsisText: '…', // https://www.algolia.com/doc/api-reference/api-parameters/snippetEllipsisText/ tagFilters: mapToAlgoliaFilters(tagsByFacet), // https://www.algolia.com/doc/api-reference/api-parameters/tagFilters/ + ...exactParams, }, }, ], From 9af1b823a805be6f35b86c3624af3286fea3d8c9 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 22 Jun 2026 11:29:24 +0100 Subject: [PATCH 2/4] DOC-1878: fix filter dropdown resetting selected filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autocomplete "Filter results" dropdown intermittently reset selected filters. Root cause: onStateChange (and the dropdown open/close handlers) called setContext({ ...state.context, : }). setContext already merges into the live context, so spreading ...state.context is redundant — and when a late async search response re-runs onStateChange with a stale state snapshot, the spread overwrites the live tagsPlugin.tags with old values, dropping the user's selections (then recovering, since the tags store still held them). Fix: pass only the changed key to setContext in all 5 call sites, so a stale snapshot can never clobber the current tags. Verified: dropdown open/close still works, selections persist, and an aggressive race (rapid toggles + overlapping in-flight queries under throttled network) no longer drops the count. Co-Authored-By: Claude Opus 4.8 --- src/partials/algolia-script.hbs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/partials/algolia-script.hbs b/src/partials/algolia-script.hbs index e3044d58..e00c9170 100644 --- a/src/partials/algolia-script.hbs +++ b/src/partials/algolia-script.hbs @@ -288,14 +288,18 @@ function initAlgolia() { prevFilters.every((p, i) => p?.label === currFilters[i]?.label); if (!same || tagsChanged) { - setContext({ ...state.context, filtersSourceItems: currFilters }); + // setContext merges into the LIVE context. Never spread ...state.context: + // onStateChange can run with a stale state snapshot (e.g. a late async search + // response), and spreading it would overwrite current tagsPlugin.tags with old + // values — the cause of the filter dropdown "resetting" to a previous count. + setContext({ filtersSourceItems: currFilters }); } // 4) Attach/detach global listeners only while the dropdown is open. const wasOpen = !!(prevState?.context?.dropdownOpen); const isOpen = !!(state?.context?.dropdownOpen); if (isOpen && !wasOpen) { - const close = () => setContext({ ...state.context, dropdownOpen: false }); + const close = () => setContext({ dropdownOpen: false }); __dropdownOutsideHandler = (e) => { const menu = __dropdownRefs.menu; const toggle = __dropdownRefs.toggle; @@ -321,11 +325,11 @@ function initAlgolia() { // 5) Update or seed preview when query/tags change or first load. if ((nextQ && nextQ !== prevQ) || tagsChanged) { - setContext({ ...state.context, preview: firstHit }); + setContext({ preview: firstHit }); return; } if (!state.context.preview && firstHit) { - setContext({ ...state.context, preview: firstHit }); + setContext({ preview: firstHit }); } }, @@ -393,7 +397,7 @@ function initAlgolia() { // Toggle dropdown through Autocomplete context (so onStateChange runs). const setDropdownOpen = (open) => { - window.__algoliaAutocompleteInstance.setContext({ ...state.context, dropdownOpen: open }); + window.__algoliaAutocompleteInstance.setContext({ dropdownOpen: open }); }; // The dropdown UI itself (button + menu) From d6fe102eb91b17abd03a03c2e15ac2b4dbcaa80b Mon Sep 17 00:00:00 2001 From: Jake Cahill <45230295+JakeSCahill@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:05:59 +0100 Subject: [PATCH 3/4] Update src/layouts/search.hbs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/layouts/search.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/search.hbs b/src/layouts/search.hbs index cd5dbfb1..44a6801d 100644 --- a/src/layouts/search.hbs +++ b/src/layouts/search.hbs @@ -15,7 +15,7 @@ {{/with}}
-