From 609d1acee0e89f857c4d9085782589c843e512c0 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 8 Apr 2026 11:24:52 +0100 Subject: [PATCH 1/5] Fix curated resources search: raise result limit and enable prefix matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search was capped at 30 results (now 500), hiding most matches. Also, search terms required exact whole-word matches — e.g. "registra" wouldn't find "registration". Now the last word in a query uses prefix matching while earlier words still require full-word matches, keeping results relevant without noise. Co-Authored-By: Claude Opus 4.6 (1M context) --- layouts/shortcodes/staticsearch.html | 2 +- static/js/search.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/layouts/shortcodes/staticsearch.html b/layouts/shortcodes/staticsearch.html index eb926e55b47..95a120bb5b4 100644 --- a/layouts/shortcodes/staticsearch.html +++ b/layouts/shortcodes/staticsearch.html @@ -1,6 +1,6 @@ {{/* {{ partial "_shared/banner.html" . }} */}}

Loading search data...

- +
\ No newline at end of file diff --git a/static/js/search.js b/static/js/search.js index 5d2fd6c60b2..4e24f9de22b 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -12,7 +12,7 @@ var searchFn = function () { var inputDecoded = normalizer.value; return " " + inputDecoded.trim().toLowerCase().replace(/[^0-9a-z ]/gi, " ").replace(/\s+/g, " ") + " "; } - var limit = 30; + var limit = 500; var minChars = 2; var searching = false; var render = function (results) { @@ -107,9 +107,10 @@ var searchFn = function () { } var newTerm = str.trim(); if (newTerm.length >= minChars && stopwords.indexOf(newTerm) < 0) { + var isPrefix = (j === terms.length - 1); termsTree.push({ weight: weight, - term: " " + str.trim() + " " + term: " " + str.trim() + (isPrefix ? "" : " ") }); } } From cd70aa4365d9be981aaecc2cb4e71c73f69c439e Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 8 Apr 2026 11:52:11 +0100 Subject: [PATCH 2/5] Require all search terms to match (AND logic) Previously each query word matched independently (OR), so "pre-registration" returned more results than "registration" because "pre" matched extra items. Now every query word must appear in an item for it to be included. Multi-word phrases and prefix matching still contribute to ranking/weight. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/search.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/static/js/search.js b/static/js/search.js index 4e24f9de22b..01b5a8084d5 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -41,10 +41,22 @@ var searchFn = function () { }); return weightResult; }; - var search = function (terms) { + var search = function (terms, requiredWords) { var results = []; searchHost.index.forEach(function (item) { if (item.tags) { + // AND logic: all query words must appear somewhere in the item + var allText = item.title + item.subtitle + item.description + item.content; + item.tags.forEach(function (tag) { allText += tag; }); + var allMatch = true; + for (var w = 0; w < requiredWords.length; w++) { + if (!~allText.indexOf(requiredWords[w])) { + allMatch = false; + break; + } + } + if (!allMatch) return; + var weight_1 = 0; terms.forEach(function (term) { if (item.title.startsWith(term.term)) { @@ -115,7 +127,15 @@ var searchFn = function () { } } } - search(termsTree); + // Build required words for AND logic (each query word must appear) + var requiredWords = []; + for (var r = 0; r < terms.length; r++) { + if (terms[r].length >= minChars && stopwords.indexOf(terms[r]) < 0) { + var isLast = (r === terms.length - 1); + requiredWords.push(" " + terms[r] + (isLast ? "" : " ")); + } + } + search(termsTree, requiredWords); searching = false; var endSearch = new Date(); $("#results").append("

Search took " + (endSearch - startSearch) + "ms.

"); From cae051a235eb57aaff00a1690c7b5e895515191a Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 8 Apr 2026 12:05:14 +0100 Subject: [PATCH 3/5] Treat hyphens as optional so pre-registration = preregistration Content normalization produces both forms (split and joined) for hyphenated words. Query normalization strips hyphens so both "pre-registration" and "preregistration" search identically. Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/search.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/static/js/search.js b/static/js/search.js index 01b5a8084d5..e0ffff734dc 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -7,10 +7,20 @@ var searchFn = function () { "of", "at", "by", "for", "with", "to", "then", "no", "not", "so", "too", "can", "and", "but"]; var normalizer = document.createElement("textarea"); + // Content normalize: "pre-registration" → " pre registration preregistration " var normalize = function (input) { normalizer.innerHTML = input; var inputDecoded = normalizer.value; - return " " + inputDecoded.trim().toLowerCase().replace(/[^0-9a-z ]/gi, " ").replace(/\s+/g, " ") + " "; + var text = inputDecoded.trim().toLowerCase(); + var withSpaces = text.replace(/-/g, " "); + var joined = (text.match(/\w+-[\w-]+/g) || []).map(function (w) { return w.replace(/-/g, ""); }).join(" "); + return " " + (withSpaces + " " + joined).replace(/[^0-9a-z ]/gi, " ").replace(/\s+/g, " ") + " "; + } + // Query normalize: strip hyphens so "pre-registration" → " preregistration " + var normalizeQuery = function (input) { + normalizer.innerHTML = input; + var inputDecoded = normalizer.value; + return " " + inputDecoded.trim().toLowerCase().replace(/-/g, "").replace(/[^0-9a-z ]/gi, " ").replace(/\s+/g, " ") + " "; } var limit = 500; var minChars = 2; @@ -94,7 +104,7 @@ var searchFn = function () { if (searching) { return; } - var term = normalize($("#searchBox").val()).trim(); + var term = normalizeQuery($("#searchBox").val()).trim(); if (term === lastTerm) { return; } From 9152b499d0eceeb13fc8f7c147f42c32137feb55 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 8 Apr 2026 13:15:06 +0100 Subject: [PATCH 4/5] Replace search list with isotope card filtering Instead of loading index.json and rendering a separate results list, the search input now filters the existing resource cards in-place via isotope. Supports AND logic (all words must match), prefix matching, and works alongside the category filter buttons. Shows a count of matching resources. Co-Authored-By: Claude Opus 4.6 (1M context) --- layouts/shortcodes/staticsearch.html | 11 +++---- themes/academic/assets/js/academic.js | 44 +++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/layouts/shortcodes/staticsearch.html b/layouts/shortcodes/staticsearch.html index 95a120bb5b4..cf25d3fd5bf 100644 --- a/layouts/shortcodes/staticsearch.html +++ b/layouts/shortcodes/staticsearch.html @@ -1,6 +1,5 @@ -{{/* {{ partial "_shared/banner.html" . }} */}} -

Loading search data...

- - -
- \ No newline at end of file + diff --git a/themes/academic/assets/js/academic.js b/themes/academic/assets/js/academic.js index 0ed7574d477..e8db3abe3a2 100644 --- a/themes/academic/assets/js/academic.js +++ b/themes/academic/assets/js/academic.js @@ -648,6 +648,9 @@ } $container.imagesLoaded(function () { + let projectFilter = $section.find('.default-project-filter').text(); + let projectSearchTerms = null; + // Initialize Isotope after all images have loaded. $container.isotope({ itemSelector: '.isotope-item', @@ -655,13 +658,48 @@ masonry: { gutter: 20 }, - filter: $section.find('.default-project-filter').text() + filter: function () { + let $this = $(this); + let filterMatch = projectFilter ? $this.is(projectFilter) : true; + if (!filterMatch) return false; + if (!projectSearchTerms) return true; + let text = $this.text(); + return projectSearchTerms.every(function (re) { return re.test(text); }); + } + }); + + // Text search on cards. + let searchTimeout; + let $searchCount = $section.find('.search-count'); + $section.find('.project-search').keyup(function () { + clearTimeout(searchTimeout); + let input = this; + searchTimeout = setTimeout(function () { + let val = $(input).val().trim(); + if (val) { + // Split on hyphens/spaces, require all words (AND logic, prefix matching) + let words = val.replace(/-/g, ' ').split(/\s+/).filter(function (w) { return w.length >= 2; }); + projectSearchTerms = words.length ? words.map(function (w) { + return new RegExp('\\b' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + }) : null; + } else { + projectSearchTerms = null; + } + $container.isotope(); + let count = $container.isotope('getFilteredItemElements').length; + let total = $container.find('.isotope-item').length; + if (projectSearchTerms) { + $searchCount.text(count + ' of ' + total + ' resources shown'); + } else { + $searchCount.text(''); + } + }, 200); }); // Filter items when filter link is clicked. $section.find('.project-filters a').click(function () { - let selector = $(this).attr('data-filter'); - $container.isotope({filter: selector}); + projectFilter = $(this).attr('data-filter'); + $container.isotope(); $(this).removeClass('active').addClass('active').siblings().removeClass('active all'); return false; }); From 32bb98253cc7eb4f62a1698720d844e3c8a5ab00 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 8 Apr 2026 13:23:09 +0100 Subject: [PATCH 5/5] Strip hyphens from card text so preregistration matches pre-registration Card text contains original hyphens, so searching "preregistration" wouldn't find cards with "pre-registration". Now hyphens are stripped from both the query (split to words) and card text (before matching). Co-Authored-By: Claude Opus 4.6 (1M context) --- themes/academic/assets/js/academic.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/themes/academic/assets/js/academic.js b/themes/academic/assets/js/academic.js index e8db3abe3a2..b87b2d89603 100644 --- a/themes/academic/assets/js/academic.js +++ b/themes/academic/assets/js/academic.js @@ -663,7 +663,7 @@ let filterMatch = projectFilter ? $this.is(projectFilter) : true; if (!filterMatch) return false; if (!projectSearchTerms) return true; - let text = $this.text(); + let text = $this.text().replace(/-/g, ''); return projectSearchTerms.every(function (re) { return re.test(text); }); } }); @@ -680,7 +680,7 @@ // Split on hyphens/spaces, require all words (AND logic, prefix matching) let words = val.replace(/-/g, ' ').split(/\s+/).filter(function (w) { return w.length >= 2; }); projectSearchTerms = words.length ? words.map(function (w) { - return new RegExp('\\b' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + return new RegExp(w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); }) : null; } else { projectSearchTerms = null;