diff --git a/README.md b/README.md
index 28da376..5e419a5 100644
--- a/README.md
+++ b/README.md
@@ -97,23 +97,24 @@ Here is a table for the available options, usage questions, troubleshooting & gu
| `success` | Function | No | A function called once the data has been loaded. |
| `debounceTime` | Number | No | Limit how many times the search function can be executed over the given time window. If no `debounceTime` (milliseconds) is provided a search will be triggered on each keystroke. |
| `searchResultTemplate` | String | No | The template of a single rendered search result. (match liquid value eg: `'
{title}'` |
+| `templateMiddleware` | Function | No | A function that processes template placeholders and can include highlighting functionality. The function receives (prop, value, template, query, matchInfo) parameters. |
## Middleware
### templateMiddleware (Function) [optional]
A function that will be called whenever a match in the template is found.
-It gets passed the current property name, property value, and the template.
+It gets passed the current property name, property value, template, query, and matchInfo.
If the function returns a non-undefined value, it gets replaced in the template.
-This can be potentially useful for manipulating URLs etc.
+This can be potentially useful for manipulating URLs or adding highlighting.
Example:
```js
SimpleJekyllSearch({
// ...other config
- templateMiddleware: function(prop, value, template) {
+ templateMiddleware: function(prop, value, template, query, matchInfo) {
if (prop === 'bar') {
return value.replace(/^\//, '')
}
@@ -139,3 +140,24 @@ SimpleJekyllSearch({
},
})
```
+
+### Highlighting Search Results
+
+The library includes built-in highlighting functionality through the `createHighlightTemplateMiddleware` function.
+
+```js
+import { createHighlightTemplateMiddleware } from 'simple-jekyll-search';
+
+SimpleJekyllSearch({
+ // ...other config
+ templateMiddleware: createHighlightTemplateMiddleware({
+ highlightClass: 'highlight', // CSS class for highlighted text
+ contextBefore: 30, // Characters before match
+ contextAfter: 30, // Characters after match
+ maxLength: 200, // Maximum total length
+ ellipsis: '...' // Text to show when truncated
+ }),
+})
+```
+
+The highlight middleware works with all search strategies and uses match information provided by the search engine for accurate highlighting.
diff --git a/cypress/e2e/highlight-middleware.cy.ts b/cypress/e2e/highlight-middleware.cy.ts
new file mode 100644
index 0000000..27d146e
--- /dev/null
+++ b/cypress/e2e/highlight-middleware.cy.ts
@@ -0,0 +1,82 @@
+describe('Highlight Middleware', () => {
+ beforeEach(() => {
+ cy.visit('/');
+ });
+
+ it('should highlight search terms with default middleware', () => {
+ cy.window().should('have.property', 'SimpleJekyllSearch');
+
+ cy.get('#search-input').type('search');
+
+ cy.get('#results-container', { timeout: 10000 }).should('not.be.empty');
+
+ cy.get('#results-container')
+ .find('.sjs-highlight')
+ .should('exist')
+ .and('contain', 'search');
+
+ cy.get('#results-container')
+ .find('.sjs-highlight')
+ .should('have.length.at.least', 1);
+ });
+
+ it('should handle case insensitive search', () => {
+ cy.get('#search-input').type('SEARCH');
+
+ cy.get('#results-container').should('not.be.empty');
+
+ cy.get('#results-container')
+ .find('.sjs-highlight')
+ .should('exist')
+ .and('contain', 'search');
+ });
+
+ it('should highlight multiple search terms', () => {
+ cy.get('#search-input').type('search test');
+
+ cy.get('#results-container').should('not.be.empty');
+
+ cy.get('#results-container')
+ .find('.sjs-highlight')
+ .should('exist');
+ });
+
+ it('should show context around matches', () => {
+ cy.get('#search-input').type('test');
+
+ cy.get('#results-container').should('not.be.empty');
+
+ cy.get('#results-container')
+ .find('.sjs-highlight')
+ .should('exist')
+ .and('contain', 'test');
+
+ cy.get('#results-container')
+ .find('.search-snippet')
+ .should('contain.text', 'test')
+ .and('not.have.text', 'test');
+ });
+
+ it('should clear results when search is cleared', () => {
+ cy.get('#search-input').type('test');
+
+ cy.get('#results-container').should('not.be.empty');
+
+ cy.get('#search-input').clear();
+
+ cy.get('#results-container').should('be.empty');
+ });
+
+ it('should handle empty search gracefully', () => {
+ cy.get('#search-input').focus();
+
+ cy.get('#results-container').should('be.empty');
+ });
+
+ it('should show no results for non-matching terms', () => {
+ cy.get('#search-input').type('nonexistentterm');
+
+ cy.get('#results-container')
+ .should('contain', 'No results found');
+ });
+});
diff --git a/dest/simple-jekyll-search.js b/dest/simple-jekyll-search.js
index ce5759d..5f27ce9 100644
--- a/dest/simple-jekyll-search.js
+++ b/dest/simple-jekyll-search.js
@@ -48,26 +48,85 @@
return typeof params.required !== "undefined" && Array.isArray(params.required);
}
}
- function fuzzySearch(text, pattern) {
- pattern = pattern.trimEnd();
- if (pattern.length === 0) return true;
- pattern = pattern.toLowerCase();
- text = text.toLowerCase();
- let remainingText = text, currentIndex = -1;
- for (const char of pattern) {
- const nextIndex = remainingText.indexOf(char);
- if (nextIndex === -1 || currentIndex !== -1 && remainingText.slice(0, nextIndex).split(" ").length - 1 > 2) {
- return false;
+ function findFuzzyMatches(text, pattern) {
+ if (!text || !pattern) return [];
+ const lowerText = text.toLowerCase();
+ const lowerPattern = pattern.toLowerCase().trim();
+ if (lowerPattern.length === 0) return [];
+ const matches = [];
+ for (let i = 0; i < lowerText.length; i++) {
+ const match = findFuzzySequenceMatch(lowerText, lowerPattern, i);
+ if (match) {
+ const isExact = match.text === lowerPattern;
+ matches.push({
+ start: match.start,
+ end: match.end,
+ text: text.substring(match.start, match.end),
+ type: isExact ? "exact" : "fuzzy"
+ });
+ i = match.end - 1;
+ }
+ }
+ return matches;
+ }
+ function findFuzzySequenceMatch(text, pattern, startPos) {
+ let textIndex = startPos;
+ let patternIndex = 0;
+ let matchStart = -1;
+ let maxGap = 10;
+ while (textIndex < text.length && patternIndex < pattern.length) {
+ if (text[textIndex] === pattern[patternIndex]) {
+ if (matchStart === -1) {
+ matchStart = textIndex;
+ }
+ patternIndex++;
+ } else if (matchStart !== -1) {
+ const gap = textIndex - matchStart;
+ if (gap > maxGap) {
+ return null;
+ }
}
- currentIndex = nextIndex;
- remainingText = remainingText.slice(nextIndex + 1);
+ textIndex++;
+ }
+ if (patternIndex === pattern.length && matchStart !== -1) {
+ return {
+ start: matchStart,
+ end: textIndex,
+ text: text.substring(matchStart, textIndex)
+ };
}
- return true;
+ return null;
}
- function literalSearch(text, criteria) {
- text = text.trim().toLowerCase();
- const pattern = criteria.endsWith(" ") ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(" ");
- return pattern.filter((word) => text.indexOf(word) >= 0).length === pattern.length;
+ function findLiteralMatches(text, criteria) {
+ if (!text || !criteria) return [];
+ const lowerText = text.toLowerCase();
+ const hasTrailingSpace = criteria.endsWith(" ");
+ const words = criteria.trim().toLowerCase().split(/\s+/);
+ const matches = [];
+ let textIndex = 0;
+ for (const word of words) {
+ if (word.length === 0) continue;
+ let wordIndex = lowerText.indexOf(word, textIndex);
+ if (hasTrailingSpace && word === words[words.length - 1]) {
+ while (wordIndex !== -1) {
+ const nextChar = lowerText[wordIndex + word.length];
+ if (!nextChar || !/\w/.test(nextChar)) {
+ break;
+ }
+ wordIndex = lowerText.indexOf(word, wordIndex + 1);
+ }
+ }
+ if (wordIndex !== -1) {
+ matches.push({
+ start: wordIndex,
+ end: wordIndex + word.length,
+ text: text.substring(wordIndex, wordIndex + word.length),
+ type: "exact"
+ });
+ textIndex = wordIndex + word.length;
+ }
+ }
+ return matches;
}
function levenshtein(a, b) {
const lenA = a.length;
@@ -80,11 +139,8 @@
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
distanceMatrix[i][j] = Math.min(
distanceMatrix[i - 1][j] + 1,
- // Removing a character from one string
distanceMatrix[i][j - 1] + 1,
- // Adding a character to one string to make it closer to the other string.
distanceMatrix[i - 1][j - 1] + cost
- // Replacing one character in a string with another
);
}
}
@@ -95,15 +151,73 @@
const similarity = 1 - distance / Math.max(pattern.length, text.length);
return similarity >= 0.3;
}
- function wildcardSearch(text, pattern) {
+ function mergeAndSortMatches(matches) {
+ if (matches.length === 0) return [];
+ const sortedMatches = matches.sort((a, b) => a.start - b.start);
+ const merged = [];
+ for (const match of sortedMatches) {
+ const lastMerged = merged[merged.length - 1];
+ if (lastMerged && match.start <= lastMerged.end) {
+ lastMerged.end = Math.max(lastMerged.end, match.end);
+ lastMerged.text = lastMerged.text + match.text.slice(lastMerged.end - match.start);
+ } else {
+ merged.push({ ...match });
+ }
+ }
+ return merged;
+ }
+ function findWildcardMatches(text, pattern) {
+ if (!text || !pattern) return [];
+ if (pattern.includes("*") || pattern.includes("?")) {
+ const wildcardMatches = findWildcardPatternMatches(text, pattern);
+ if (wildcardMatches.length > 0) {
+ return mergeAndSortMatches(wildcardMatches);
+ }
+ }
+ const literalMatches = findLiteralMatches(text, pattern);
+ if (literalMatches.length > 0) {
+ return literalMatches.map((match) => ({
+ ...match,
+ type: "wildcard"
+ }));
+ }
+ if (levenshteinSearch(text, pattern)) {
+ return [{
+ start: 0,
+ end: text.length,
+ text,
+ type: "wildcard"
+ }];
+ }
+ return [];
+ }
+ function findWildcardPatternMatches(text, pattern) {
+ const matches = [];
const regexPattern = pattern.replace(/\*/g, ".*");
- const regex = new RegExp(`^${regexPattern}$`, "i");
- if (regex.test(text)) return true;
- return levenshteinSearch(text, pattern);
+ const regex = new RegExp(regexPattern, "gi");
+ let match;
+ let lastIndex = 0;
+ while ((match = regex.exec(text)) !== null) {
+ if (match.index === lastIndex && match[0].length === 0) {
+ break;
+ }
+ lastIndex = match.index;
+ matches.push({
+ start: match.index,
+ end: match.index + match[0].length,
+ text: match[0],
+ type: "wildcard"
+ });
+ if (match[0].length === text.length) {
+ break;
+ }
+ }
+ return matches;
}
class SearchStrategy {
- constructor(matchFunction) {
+ constructor(matchFunction, findMatchesFunction) {
this.matchFunction = matchFunction;
+ this.findMatchesFunction = findMatchesFunction;
}
matches(text, criteria) {
if (text === null || text.trim() === "" || !criteria) {
@@ -111,14 +225,56 @@
}
return this.matchFunction(text, criteria);
}
+ findMatches(text, criteria) {
+ if (text === null || text.trim() === "" || !criteria) {
+ return [];
+ }
+ return this.findMatchesFunction(text, criteria);
+ }
}
- const LiteralSearchStrategy = new SearchStrategy(literalSearch);
- const FuzzySearchStrategy = new SearchStrategy((text, criteria) => {
- return fuzzySearch(text, criteria) || literalSearch(text, criteria);
- });
- const WildcardSearchStrategy = new SearchStrategy((text, criteria) => {
- return wildcardSearch(text, criteria) || literalSearch(text, criteria);
- });
+ const LiteralSearchStrategy = new SearchStrategy(
+ (text, criteria) => {
+ if (!text || !criteria) return false;
+ const lowerText = text.trim().toLowerCase();
+ const pattern = criteria.endsWith(" ") ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(" ");
+ return pattern.filter((word) => lowerText.indexOf(word) >= 0).length === pattern.length;
+ },
+ findLiteralMatches
+ );
+ const FuzzySearchStrategy = new SearchStrategy(
+ (text, criteria) => {
+ const pattern = criteria.trimEnd();
+ if (pattern.length === 0) return true;
+ const lowerPattern = pattern.toLowerCase();
+ const lowerText = text.toLowerCase();
+ let remainingText = lowerText, currentIndex = -1;
+ for (const char of lowerPattern) {
+ const nextIndex = remainingText.indexOf(char);
+ if (nextIndex === -1 || currentIndex !== -1 && remainingText.slice(0, nextIndex).split(" ").length - 1 > 2) {
+ const lowerText2 = text.trim().toLowerCase();
+ const pattern2 = criteria.endsWith(" ") ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(" ");
+ return pattern2.filter((word) => lowerText2.indexOf(word) >= 0).length === pattern2.length;
+ }
+ currentIndex = nextIndex;
+ remainingText = remainingText.slice(nextIndex + 1);
+ }
+ return true;
+ },
+ findFuzzyMatches
+ );
+ const WildcardSearchStrategy = new SearchStrategy(
+ (text, criteria) => {
+ if (criteria.includes("*") || criteria.includes("?")) {
+ const wildcardMatches = findWildcardMatches(text, criteria);
+ return wildcardMatches.length > 0;
+ }
+ if (!text || !criteria) return false;
+ const lowerText = text.trim().toLowerCase();
+ const pattern = criteria.endsWith(" ") ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(" ");
+ return pattern.filter((word) => lowerText.indexOf(word) >= 0).length === pattern.length;
+ },
+ findWildcardMatches
+ );
function merge(target, source) {
return { ...target, ...source };
}
@@ -157,7 +313,7 @@
success: function() {
},
searchResultTemplate: '{title}',
- templateMiddleware: (_prop, _value, _template) => void 0,
+ templateMiddleware: (_prop, _value, _template, _query) => void 0,
sortMiddleware: NoSort,
noResultsText: "No results found",
limit: 10,
@@ -238,12 +394,19 @@
return matches;
}
findMatchesInObject(obj, criteria) {
+ let hasMatch = false;
+ const result = { ...obj };
+ result._matchInfo = {};
for (const key in obj) {
if (!this.isExcluded(obj[key]) && this.options.searchStrategy.matches(obj[key], criteria)) {
- return obj;
+ hasMatch = true;
+ const matchInfo = this.options.searchStrategy.findMatches(obj[key], criteria);
+ if (matchInfo && matchInfo.length > 0) {
+ result._matchInfo[key] = matchInfo;
+ }
}
}
- return void 0;
+ return hasMatch ? result : void 0;
}
isExcluded(term) {
for (const excludedTerm of this.options.exclude) {
@@ -282,9 +445,10 @@
options.middleware = _options.middleware;
}
}
- function compile(data) {
+ function compile(data, query) {
return options.template.replace(options.pattern, function(match, prop) {
- const value = options.middleware(prop, data[prop], options.template);
+ const matchInfo = data._matchInfo && data._matchInfo[prop] ? data._matchInfo[prop] : void 0;
+ const value = options.middleware(prop, data[prop], options.template, query, matchInfo);
if (typeof value !== "undefined") {
return value;
}
@@ -357,7 +521,7 @@
results.forEach((result) => {
result.query = query;
const div = document.createElement("div");
- div.innerHTML = compile(result);
+ div.innerHTML = compile(result, query);
fragment.appendChild(div);
});
this.options.resultsContainer.appendChild(fragment);
@@ -392,13 +556,173 @@
return rv;
}
};
+ function createHighlightMiddleware(options2 = {}) {
+ const {
+ highlightClass = "sjs-highlight",
+ contextBefore = 50,
+ contextAfter = 50,
+ maxLength = 250,
+ ellipsis = "..."
+ } = options2;
+ return function(result, query) {
+ if (!query || !result) {
+ return result;
+ }
+ const highlightedResult = { ...result };
+ const textFields = ["title", "desc", "content", "excerpt"];
+ for (const field of textFields) {
+ if (highlightedResult[field] && typeof highlightedResult[field] === "string") {
+ const highlighted = highlightText(
+ highlightedResult[field],
+ query,
+ { highlightClass, contextBefore, contextAfter, maxLength, ellipsis }
+ );
+ if (highlighted.highlightedText !== highlightedResult[field]) {
+ highlightedResult[field] = highlighted.highlightedText;
+ }
+ }
+ }
+ return highlightedResult;
+ };
+ }
+ function highlightText(text, query, options2 = {}) {
+ const {
+ highlightClass = "sjs-highlight",
+ contextBefore = 50,
+ contextAfter = 50,
+ maxLength = 250,
+ ellipsis = "..."
+ } = options2;
+ if (!text || !query) {
+ return { highlightedText: text, matchCount: 0 };
+ }
+ const originalText = text;
+ const searchTerms = query.trim().toLowerCase().split(/\s+/).filter((term) => term.length > 0);
+ if (searchTerms.length === 0) {
+ return { highlightedText: text, matchCount: 0 };
+ }
+ const matches = [];
+ for (const term of searchTerms) {
+ let index = 0;
+ while (index < text.length) {
+ const found = text.toLowerCase().indexOf(term, index);
+ if (found === -1) break;
+ matches.push({
+ start: found,
+ end: found + term.length,
+ term: text.substring(found, found + term.length)
+ });
+ index = found + 1;
+ }
+ }
+ if (matches.length === 0) {
+ return { highlightedText: text, matchCount: 0 };
+ }
+ matches.sort((a, b) => a.start - b.start);
+ const mergedMatches = [];
+ for (const match of matches) {
+ if (mergedMatches.length === 0 || mergedMatches[mergedMatches.length - 1].end < match.start) {
+ mergedMatches.push(match);
+ } else {
+ const lastMatch = mergedMatches[mergedMatches.length - 1];
+ lastMatch.end = Math.max(lastMatch.end, match.end);
+ lastMatch.term = text.substring(lastMatch.start, lastMatch.end);
+ }
+ }
+ if (text.length <= maxLength) {
+ let highlightedText2 = text;
+ for (let i = mergedMatches.length - 1; i >= 0; i--) {
+ const match = mergedMatches[i];
+ const before = highlightedText2.substring(0, match.start);
+ const after = highlightedText2.substring(match.end);
+ const matchText = highlightedText2.substring(match.start, match.end);
+ highlightedText2 = before + `${matchText}` + after;
+ }
+ return { highlightedText: highlightedText2, matchCount: mergedMatches.length };
+ }
+ let highlightedText = "";
+ let totalLength = 0;
+ let lastEnd = 0;
+ for (let i = 0; i < mergedMatches.length; i++) {
+ const match = mergedMatches[i];
+ const contextStart = Math.max(lastEnd, match.start - contextBefore);
+ const contextEnd = Math.min(text.length, match.end + contextAfter);
+ if (contextStart > lastEnd && lastEnd > 0) {
+ highlightedText += ellipsis;
+ totalLength += ellipsis.length;
+ }
+ if (contextStart < match.start) {
+ const beforeText = text.substring(contextStart, match.start);
+ highlightedText += beforeText;
+ totalLength += beforeText.length;
+ }
+ const matchText = text.substring(match.start, match.end);
+ highlightedText += `${matchText}`;
+ totalLength += matchText.length;
+ if (match.end < contextEnd) {
+ const afterText = text.substring(match.end, contextEnd);
+ highlightedText += afterText;
+ totalLength += afterText.length;
+ }
+ lastEnd = contextEnd;
+ if (totalLength >= maxLength) {
+ if (contextEnd < text.length) {
+ highlightedText += ellipsis;
+ }
+ break;
+ }
+ }
+ return {
+ highlightedText: highlightedText || originalText,
+ matchCount: mergedMatches.length
+ };
+ }
+ const defaultHighlightMiddleware = createHighlightMiddleware();
+ function highlightWithMatchInfo(text, matchInfo, options2) {
+ if (matchInfo.length === 0) return text;
+ const sortedMatches = [...matchInfo].sort((a, b) => b.start - a.start);
+ let highlightedText = text;
+ for (const match of sortedMatches) {
+ const before = highlightedText.substring(0, match.start);
+ const after = highlightedText.substring(match.end);
+ const matchText = highlightedText.substring(match.start, match.end);
+ highlightedText = before + `${matchText}` + after;
+ }
+ return highlightedText;
+ }
+ function createHighlightTemplateMiddleware(options2 = {}) {
+ const highlightOptions = {
+ highlightClass: "sjs-highlight",
+ ...options2
+ };
+ return function(prop, value, _template, query, matchInfo) {
+ if ((prop === "content" || prop === "desc") && query && typeof value === "string") {
+ if (matchInfo && matchInfo.length > 0) {
+ const highlighted = highlightWithMatchInfo(value, matchInfo, highlightOptions);
+ return highlighted !== value ? highlighted : void 0;
+ }
+ }
+ return void 0;
+ };
+ }
+ const defaultHighlightTemplateMiddleware = createHighlightTemplateMiddleware();
function SimpleJekyllSearch(options2) {
const instance = new SimpleJekyllSearch$1();
return instance.init(options2);
}
if (typeof window !== "undefined") {
window.SimpleJekyllSearch = SimpleJekyllSearch;
+ window.SimpleJekyllSearch.createHighlightMiddleware = createHighlightMiddleware;
+ window.SimpleJekyllSearch.createHighlightTemplateMiddleware = createHighlightTemplateMiddleware;
+ window.SimpleJekyllSearch.highlightText = highlightText;
+ window.SimpleJekyllSearch.defaultHighlightMiddleware = defaultHighlightMiddleware;
+ window.SimpleJekyllSearch.defaultHighlightTemplateMiddleware = defaultHighlightTemplateMiddleware;
}
+ exports2.createHighlightMiddleware = createHighlightMiddleware;
+ exports2.createHighlightTemplateMiddleware = createHighlightTemplateMiddleware;
exports2.default = SimpleJekyllSearch;
+ exports2.defaultHighlightMiddleware = defaultHighlightMiddleware;
+ exports2.defaultHighlightTemplateMiddleware = defaultHighlightTemplateMiddleware;
+ exports2.highlightText = highlightText;
Object.defineProperties(exports2, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
});
diff --git a/dest/simple-jekyll-search.min.js b/dest/simple-jekyll-search.min.js
index 58f1487..63a5daf 100644
--- a/dest/simple-jekyll-search.min.js
+++ b/dest/simple-jekyll-search.min.js
@@ -4,4 +4,4 @@
* Copyright 2025-2025, Sylhare
* Licensed under the MIT License.
*/
-(function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?factory(exports):typeof define==="function"&&define.amd?define(["exports"],factory):(global=typeof globalThis!=="undefined"?globalThis:global||self,factory(global.SimpleJekyllSearch={}))})(this,(function(exports2){"use strict";function load(location,callback){const xhr=getXHR();xhr.open("GET",location,true);xhr.onreadystatechange=createStateChangeListener(xhr,callback);xhr.send()}function createStateChangeListener(xhr,callback){return function(){if(xhr.readyState===4&&xhr.status===200){try{callback(null,JSON.parse(xhr.responseText))}catch(err){callback(err instanceof Error?err:new Error(String(err)),null)}}}}function getXHR(){return window.XMLHttpRequest?new window.XMLHttpRequest:new window.ActiveXObject("Microsoft.XMLHTTP")}class OptionsValidator{constructor(params){if(!this.validateParams(params)){throw new Error("-- OptionsValidator: required options missing")}this.requiredOptions=params.required}getRequiredOptions(){return this.requiredOptions}validate(parameters){const errors=[];this.requiredOptions.forEach((requiredOptionName=>{if(typeof parameters[requiredOptionName]==="undefined"){errors.push(requiredOptionName)}}));return errors}validateParams(params){if(!params){return false}return typeof params.required!=="undefined"&&Array.isArray(params.required)}}function fuzzySearch(text,pattern){pattern=pattern.trimEnd();if(pattern.length===0)return true;pattern=pattern.toLowerCase();text=text.toLowerCase();let remainingText=text,currentIndex=-1;for(const char of pattern){const nextIndex=remainingText.indexOf(char);if(nextIndex===-1||currentIndex!==-1&&remainingText.slice(0,nextIndex).split(" ").length-1>2){return false}currentIndex=nextIndex;remainingText=remainingText.slice(nextIndex+1)}return true}function literalSearch(text,criteria){text=text.trim().toLowerCase();const pattern=criteria.endsWith(" ")?[criteria.toLowerCase()]:criteria.trim().toLowerCase().split(" ");return pattern.filter((word=>text.indexOf(word)>=0)).length===pattern.length}function levenshtein(a,b){const lenA=a.length;const lenB=b.length;const distanceMatrix=Array.from({length:lenA+1},(()=>Array(lenB+1).fill(0)));for(let i=0;i<=lenA;i++)distanceMatrix[i][0]=i;for(let j=0;j<=lenB;j++)distanceMatrix[0][j]=j;for(let i=1;i<=lenA;i++){for(let j=1;j<=lenB;j++){const cost=a[i-1]===b[j-1]?0:1;distanceMatrix[i][j]=Math.min(distanceMatrix[i-1][j]+1,distanceMatrix[i][j-1]+1,distanceMatrix[i-1][j-1]+cost)}}return distanceMatrix[lenA][lenB]}function levenshteinSearch(text,pattern){const distance=levenshtein(pattern,text);const similarity=1-distance/Math.max(pattern.length,text.length);return similarity>=.3}function wildcardSearch(text,pattern){const regexPattern=pattern.replace(/\*/g,".*");const regex=new RegExp(`^${regexPattern}$`,"i");if(regex.test(text))return true;return levenshteinSearch(text,pattern)}class SearchStrategy{constructor(matchFunction){this.matchFunction=matchFunction}matches(text,criteria){if(text===null||text.trim()===""||!criteria){return false}return this.matchFunction(text,criteria)}}const LiteralSearchStrategy=new SearchStrategy(literalSearch);const FuzzySearchStrategy=new SearchStrategy(((text,criteria)=>fuzzySearch(text,criteria)||literalSearch(text,criteria)));const WildcardSearchStrategy=new SearchStrategy(((text,criteria)=>wildcardSearch(text,criteria)||literalSearch(text,criteria)));function merge(target,source){return{...target,...source}}function isJSON(json){try{return!!(json instanceof Object&&JSON.parse(JSON.stringify(json)))}catch(_err){return false}}function NoSort(){return 0}function isObject(obj){return Boolean(obj)&&Object.prototype.toString.call(obj)==="[object Object]"}function clone(input){if(input===null||typeof input!=="object"){return input}if(Array.isArray(input)){return input.map((item=>clone(item)))}const output={};for(const key in input){if(Object.prototype.hasOwnProperty.call(input,key)){output[key]=clone(input[key])}}return output}const DEFAULT_OPTIONS={searchInput:null,resultsContainer:null,json:[],success:function(){},searchResultTemplate:'{title}',templateMiddleware:(_prop,_value,_template)=>void 0,sortMiddleware:NoSort,noResultsText:"No results found",limit:10,fuzzy:false,strategy:"literal",debounceTime:null,exclude:[],onSearch:()=>{}};const REQUIRED_OPTIONS=["searchInput","resultsContainer","json"];const WHITELISTED_KEYS=new Set(["Enter","Shift","CapsLock","ArrowLeft","ArrowUp","ArrowRight","ArrowDown","Meta"]);class Repository{constructor(initialOptions={}){this.data=[];this.setOptions(initialOptions)}put(input){if(isObject(input)){return this.addObject(input)}if(Array.isArray(input)){return this.addArray(input)}return void 0}clear(){this.data.length=0;return this.data}search(criteria){if(!criteria){return[]}return clone(this.findMatches(this.data,criteria).sort(this.options.sortMiddleware))}setOptions(newOptions){this.options={fuzzy:(newOptions==null?void 0:newOptions.fuzzy)||DEFAULT_OPTIONS.fuzzy,limit:(newOptions==null?void 0:newOptions.limit)||DEFAULT_OPTIONS.limit,searchStrategy:this.searchStrategy((newOptions==null?void 0:newOptions.strategy)||newOptions.fuzzy&&"fuzzy"||DEFAULT_OPTIONS.strategy),sortMiddleware:(newOptions==null?void 0:newOptions.sortMiddleware)||DEFAULT_OPTIONS.sortMiddleware,exclude:(newOptions==null?void 0:newOptions.exclude)||DEFAULT_OPTIONS.exclude,strategy:(newOptions==null?void 0:newOptions.strategy)||DEFAULT_OPTIONS.strategy}}addObject(obj){this.data.push(obj);return this.data}addArray(arr){const added=[];this.clear();for(const item of arr){if(isObject(item)){added.push(this.addObject(item)[0])}}return added}findMatches(data,criteria){const matches=[];for(let i=0;i{if(err){this.throwError(`Failed to load JSON from ${url}: ${err.message}`)}this.initWithJSON(json)}))}registerInput(){this.options.searchInput.addEventListener("input",(e=>{const inputEvent=e;if(!WHITELISTED_KEYS.has(inputEvent.key)){this.emptyResultsContainer();this.debounce((()=>{this.search(e.target.value)}),this.options.debounceTime??null)}}))}search(query){var _a,_b;if((query==null?void 0:query.trim().length)>0){this.emptyResultsContainer();const results=this.repository.search(query);this.render(results,query);(_b=(_a=this.options).onSearch)==null?void 0:_b.call(_a)}}render(results,query){if(results.length===0){this.options.resultsContainer.insertAdjacentHTML("beforeend",this.options.noResultsText);return}const fragment=document.createDocumentFragment();results.forEach((result=>{result.query=query;const div=document.createElement("div");div.innerHTML=compile(result);fragment.appendChild(div)}));this.options.resultsContainer.appendChild(fragment)}init(_options){var _a;const errors=this.optionsValidator.validate(_options);if(errors.length>0){this.throwError(`Missing required options: ${REQUIRED_OPTIONS.join(", ")}`)}this.options=merge(this.options,_options);setOptions({template:this.options.searchResultTemplate,middleware:this.options.templateMiddleware});this.repository.setOptions({fuzzy:this.options.fuzzy,limit:this.options.limit,sortMiddleware:this.options.sortMiddleware,strategy:this.options.strategy,exclude:this.options.exclude});if(isJSON(this.options.json)){this.initWithJSON(this.options.json)}else{this.initWithURL(this.options.json)}const rv={search:this.search.bind(this)};(_a=this.options.success)==null?void 0:_a.call(rv);return rv}};function SimpleJekyllSearch(options2){const instance=new SimpleJekyllSearch$1;return instance.init(options2)}if(typeof window!=="undefined"){window.SimpleJekyllSearch=SimpleJekyllSearch}exports2.default=SimpleJekyllSearch;Object.defineProperties(exports2,{__esModule:{value:true},[Symbol.toStringTag]:{value:"Module"}})}));
\ No newline at end of file
+(function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?factory(exports):typeof define==="function"&&define.amd?define(["exports"],factory):(global=typeof globalThis!=="undefined"?globalThis:global||self,factory(global.SimpleJekyllSearch={}))})(this,(function(exports2){"use strict";function load(location,callback){const xhr=getXHR();xhr.open("GET",location,true);xhr.onreadystatechange=createStateChangeListener(xhr,callback);xhr.send()}function createStateChangeListener(xhr,callback){return function(){if(xhr.readyState===4&&xhr.status===200){try{callback(null,JSON.parse(xhr.responseText))}catch(err){callback(err instanceof Error?err:new Error(String(err)),null)}}}}function getXHR(){return window.XMLHttpRequest?new window.XMLHttpRequest:new window.ActiveXObject("Microsoft.XMLHTTP")}class OptionsValidator{constructor(params){if(!this.validateParams(params)){throw new Error("-- OptionsValidator: required options missing")}this.requiredOptions=params.required}getRequiredOptions(){return this.requiredOptions}validate(parameters){const errors=[];this.requiredOptions.forEach((requiredOptionName=>{if(typeof parameters[requiredOptionName]==="undefined"){errors.push(requiredOptionName)}}));return errors}validateParams(params){if(!params){return false}return typeof params.required!=="undefined"&&Array.isArray(params.required)}}function findFuzzyMatches(text,pattern){if(!text||!pattern)return[];const lowerText=text.toLowerCase();const lowerPattern=pattern.toLowerCase().trim();if(lowerPattern.length===0)return[];const matches=[];for(let i=0;imaxGap){return null}}textIndex++}if(patternIndex===pattern.length&&matchStart!==-1){return{start:matchStart,end:textIndex,text:text.substring(matchStart,textIndex)}}return null}function findLiteralMatches(text,criteria){if(!text||!criteria)return[];const lowerText=text.toLowerCase();const hasTrailingSpace=criteria.endsWith(" ");const words=criteria.trim().toLowerCase().split(/\s+/);const matches=[];let textIndex=0;for(const word of words){if(word.length===0)continue;let wordIndex=lowerText.indexOf(word,textIndex);if(hasTrailingSpace&&word===words[words.length-1]){while(wordIndex!==-1){const nextChar=lowerText[wordIndex+word.length];if(!nextChar||!/\w/.test(nextChar)){break}wordIndex=lowerText.indexOf(word,wordIndex+1)}}if(wordIndex!==-1){matches.push({start:wordIndex,end:wordIndex+word.length,text:text.substring(wordIndex,wordIndex+word.length),type:"exact"});textIndex=wordIndex+word.length}}return matches}function levenshtein(a,b){const lenA=a.length;const lenB=b.length;const distanceMatrix=Array.from({length:lenA+1},(()=>Array(lenB+1).fill(0)));for(let i=0;i<=lenA;i++)distanceMatrix[i][0]=i;for(let j=0;j<=lenB;j++)distanceMatrix[0][j]=j;for(let i=1;i<=lenA;i++){for(let j=1;j<=lenB;j++){const cost=a[i-1]===b[j-1]?0:1;distanceMatrix[i][j]=Math.min(distanceMatrix[i-1][j]+1,distanceMatrix[i][j-1]+1,distanceMatrix[i-1][j-1]+cost)}}return distanceMatrix[lenA][lenB]}function levenshteinSearch(text,pattern){const distance=levenshtein(pattern,text);const similarity=1-distance/Math.max(pattern.length,text.length);return similarity>=.3}function mergeAndSortMatches(matches){if(matches.length===0)return[];const sortedMatches=matches.sort(((a,b)=>a.start-b.start));const merged=[];for(const match of sortedMatches){const lastMerged=merged[merged.length-1];if(lastMerged&&match.start<=lastMerged.end){lastMerged.end=Math.max(lastMerged.end,match.end);lastMerged.text=lastMerged.text+match.text.slice(lastMerged.end-match.start)}else{merged.push({...match})}}return merged}function findWildcardMatches(text,pattern){if(!text||!pattern)return[];if(pattern.includes("*")||pattern.includes("?")){const wildcardMatches=findWildcardPatternMatches(text,pattern);if(wildcardMatches.length>0){return mergeAndSortMatches(wildcardMatches)}}const literalMatches=findLiteralMatches(text,pattern);if(literalMatches.length>0){return literalMatches.map((match=>({...match,type:"wildcard"})))}if(levenshteinSearch(text,pattern)){return[{start:0,end:text.length,text:text,type:"wildcard"}]}return[]}function findWildcardPatternMatches(text,pattern){const matches=[];const regexPattern=pattern.replace(/\*/g,".*");const regex=new RegExp(regexPattern,"gi");let match;let lastIndex=0;while((match=regex.exec(text))!==null){if(match.index===lastIndex&&match[0].length===0){break}lastIndex=match.index;matches.push({start:match.index,end:match.index+match[0].length,text:match[0],type:"wildcard"});if(match[0].length===text.length){break}}return matches}class SearchStrategy{constructor(matchFunction,findMatchesFunction){this.matchFunction=matchFunction;this.findMatchesFunction=findMatchesFunction}matches(text,criteria){if(text===null||text.trim()===""||!criteria){return false}return this.matchFunction(text,criteria)}findMatches(text,criteria){if(text===null||text.trim()===""||!criteria){return[]}return this.findMatchesFunction(text,criteria)}}const LiteralSearchStrategy=new SearchStrategy(((text,criteria)=>{if(!text||!criteria)return false;const lowerText=text.trim().toLowerCase();const pattern=criteria.endsWith(" ")?[criteria.toLowerCase()]:criteria.trim().toLowerCase().split(" ");return pattern.filter((word=>lowerText.indexOf(word)>=0)).length===pattern.length}),findLiteralMatches);const FuzzySearchStrategy=new SearchStrategy(((text,criteria)=>{const pattern=criteria.trimEnd();if(pattern.length===0)return true;const lowerPattern=pattern.toLowerCase();const lowerText=text.toLowerCase();let remainingText=lowerText,currentIndex=-1;for(const char of lowerPattern){const nextIndex=remainingText.indexOf(char);if(nextIndex===-1||currentIndex!==-1&&remainingText.slice(0,nextIndex).split(" ").length-1>2){const lowerText2=text.trim().toLowerCase();const pattern2=criteria.endsWith(" ")?[criteria.toLowerCase()]:criteria.trim().toLowerCase().split(" ");return pattern2.filter((word=>lowerText2.indexOf(word)>=0)).length===pattern2.length}currentIndex=nextIndex;remainingText=remainingText.slice(nextIndex+1)}return true}),findFuzzyMatches);const WildcardSearchStrategy=new SearchStrategy(((text,criteria)=>{if(criteria.includes("*")||criteria.includes("?")){const wildcardMatches=findWildcardMatches(text,criteria);return wildcardMatches.length>0}if(!text||!criteria)return false;const lowerText=text.trim().toLowerCase();const pattern=criteria.endsWith(" ")?[criteria.toLowerCase()]:criteria.trim().toLowerCase().split(" ");return pattern.filter((word=>lowerText.indexOf(word)>=0)).length===pattern.length}),findWildcardMatches);function merge(target,source){return{...target,...source}}function isJSON(json){try{return!!(json instanceof Object&&JSON.parse(JSON.stringify(json)))}catch(_err){return false}}function NoSort(){return 0}function isObject(obj){return Boolean(obj)&&Object.prototype.toString.call(obj)==="[object Object]"}function clone(input){if(input===null||typeof input!=="object"){return input}if(Array.isArray(input)){return input.map((item=>clone(item)))}const output={};for(const key in input){if(Object.prototype.hasOwnProperty.call(input,key)){output[key]=clone(input[key])}}return output}const DEFAULT_OPTIONS={searchInput:null,resultsContainer:null,json:[],success:function(){},searchResultTemplate:'{title}',templateMiddleware:(_prop,_value,_template,_query)=>void 0,sortMiddleware:NoSort,noResultsText:"No results found",limit:10,fuzzy:false,strategy:"literal",debounceTime:null,exclude:[],onSearch:()=>{}};const REQUIRED_OPTIONS=["searchInput","resultsContainer","json"];const WHITELISTED_KEYS=new Set(["Enter","Shift","CapsLock","ArrowLeft","ArrowUp","ArrowRight","ArrowDown","Meta"]);class Repository{constructor(initialOptions={}){this.data=[];this.setOptions(initialOptions)}put(input){if(isObject(input)){return this.addObject(input)}if(Array.isArray(input)){return this.addArray(input)}return void 0}clear(){this.data.length=0;return this.data}search(criteria){if(!criteria){return[]}return clone(this.findMatches(this.data,criteria).sort(this.options.sortMiddleware))}setOptions(newOptions){this.options={fuzzy:(newOptions==null?void 0:newOptions.fuzzy)||DEFAULT_OPTIONS.fuzzy,limit:(newOptions==null?void 0:newOptions.limit)||DEFAULT_OPTIONS.limit,searchStrategy:this.searchStrategy((newOptions==null?void 0:newOptions.strategy)||newOptions.fuzzy&&"fuzzy"||DEFAULT_OPTIONS.strategy),sortMiddleware:(newOptions==null?void 0:newOptions.sortMiddleware)||DEFAULT_OPTIONS.sortMiddleware,exclude:(newOptions==null?void 0:newOptions.exclude)||DEFAULT_OPTIONS.exclude,strategy:(newOptions==null?void 0:newOptions.strategy)||DEFAULT_OPTIONS.strategy}}addObject(obj){this.data.push(obj);return this.data}addArray(arr){const added=[];this.clear();for(const item of arr){if(isObject(item)){added.push(this.addObject(item)[0])}}return added}findMatches(data,criteria){const matches=[];for(let i=0;i0){result._matchInfo[key]=matchInfo}}}return hasMatch?result:void 0}isExcluded(term){for(const excludedTerm of this.options.exclude){if(new RegExp(excludedTerm).test(String(term))){return true}}return false}searchStrategy(strategy){switch(strategy){case"fuzzy":return FuzzySearchStrategy;case"wildcard":return WildcardSearchStrategy;default:return LiteralSearchStrategy}}}const options={pattern:/\{(.*?)\}/g,template:"",middleware:function(){return void 0}};function setOptions(_options){if(_options.pattern){options.pattern=_options.pattern}if(_options.template){options.template=_options.template}if(typeof _options.middleware==="function"){options.middleware=_options.middleware}}function compile(data,query){return options.template.replace(options.pattern,(function(match,prop){const matchInfo=data._matchInfo&&data._matchInfo[prop]?data._matchInfo[prop]:void 0;const value=options.middleware(prop,data[prop],options.template,query,matchInfo);if(typeof value!=="undefined"){return value}return data[prop]||match}))}let SimpleJekyllSearch$1=class SimpleJekyllSearch{constructor(){this.debounceTimerHandle=null;this.options={...DEFAULT_OPTIONS};this.repository=new Repository;this.optionsValidator=new OptionsValidator({required:REQUIRED_OPTIONS})}debounce(func,delayMillis){if(delayMillis){if(this.debounceTimerHandle){clearTimeout(this.debounceTimerHandle)}this.debounceTimerHandle=setTimeout(func,delayMillis)}else{func()}}throwError(message){throw new Error(`SimpleJekyllSearch --- ${message}`)}emptyResultsContainer(){this.options.resultsContainer.innerHTML=""}initWithJSON(json){this.repository.put(json);this.registerInput()}initWithURL(url){load(url,((err,json)=>{if(err){this.throwError(`Failed to load JSON from ${url}: ${err.message}`)}this.initWithJSON(json)}))}registerInput(){this.options.searchInput.addEventListener("input",(e=>{const inputEvent=e;if(!WHITELISTED_KEYS.has(inputEvent.key)){this.emptyResultsContainer();this.debounce((()=>{this.search(e.target.value)}),this.options.debounceTime??null)}}))}search(query){var _a,_b;if((query==null?void 0:query.trim().length)>0){this.emptyResultsContainer();const results=this.repository.search(query);this.render(results,query);(_b=(_a=this.options).onSearch)==null?void 0:_b.call(_a)}}render(results,query){if(results.length===0){this.options.resultsContainer.insertAdjacentHTML("beforeend",this.options.noResultsText);return}const fragment=document.createDocumentFragment();results.forEach((result=>{result.query=query;const div=document.createElement("div");div.innerHTML=compile(result,query);fragment.appendChild(div)}));this.options.resultsContainer.appendChild(fragment)}init(_options){var _a;const errors=this.optionsValidator.validate(_options);if(errors.length>0){this.throwError(`Missing required options: ${REQUIRED_OPTIONS.join(", ")}`)}this.options=merge(this.options,_options);setOptions({template:this.options.searchResultTemplate,middleware:this.options.templateMiddleware});this.repository.setOptions({fuzzy:this.options.fuzzy,limit:this.options.limit,sortMiddleware:this.options.sortMiddleware,strategy:this.options.strategy,exclude:this.options.exclude});if(isJSON(this.options.json)){this.initWithJSON(this.options.json)}else{this.initWithURL(this.options.json)}const rv={search:this.search.bind(this)};(_a=this.options.success)==null?void 0:_a.call(rv);return rv}};function createHighlightMiddleware(options2={}){const{highlightClass:highlightClass="sjs-highlight",contextBefore:contextBefore=50,contextAfter:contextAfter=50,maxLength:maxLength=250,ellipsis:ellipsis="..."}=options2;return function(result,query){if(!query||!result){return result}const highlightedResult={...result};const textFields=["title","desc","content","excerpt"];for(const field of textFields){if(highlightedResult[field]&&typeof highlightedResult[field]==="string"){const highlighted=highlightText(highlightedResult[field],query,{highlightClass:highlightClass,contextBefore:contextBefore,contextAfter:contextAfter,maxLength:maxLength,ellipsis:ellipsis});if(highlighted.highlightedText!==highlightedResult[field]){highlightedResult[field]=highlighted.highlightedText}}}return highlightedResult}}function highlightText(text,query,options2={}){const{highlightClass:highlightClass="sjs-highlight",contextBefore:contextBefore=50,contextAfter:contextAfter=50,maxLength:maxLength=250,ellipsis:ellipsis="..."}=options2;if(!text||!query){return{highlightedText:text,matchCount:0}}const originalText=text;const searchTerms=query.trim().toLowerCase().split(/\s+/).filter((term=>term.length>0));if(searchTerms.length===0){return{highlightedText:text,matchCount:0}}const matches=[];for(const term of searchTerms){let index=0;while(indexa.start-b.start));const mergedMatches=[];for(const match of matches){if(mergedMatches.length===0||mergedMatches[mergedMatches.length-1].end=0;i--){const match=mergedMatches[i];const before=highlightedText2.substring(0,match.start);const after=highlightedText2.substring(match.end);const matchText=highlightedText2.substring(match.start,match.end);highlightedText2=before+`${matchText}`+after}return{highlightedText:highlightedText2,matchCount:mergedMatches.length}}let highlightedText="";let totalLength=0;let lastEnd=0;for(let i=0;ilastEnd&&lastEnd>0){highlightedText+=ellipsis;totalLength+=ellipsis.length}if(contextStart${matchText}`;totalLength+=matchText.length;if(match.end=maxLength){if(contextEndb.start-a.start));let highlightedText=text;for(const match of sortedMatches){const before=highlightedText.substring(0,match.start);const after=highlightedText.substring(match.end);const matchText=highlightedText.substring(match.start,match.end);highlightedText=before+`${matchText}`+after}return highlightedText}function createHighlightTemplateMiddleware(options2={}){const highlightOptions={highlightClass:"sjs-highlight",...options2};return function(prop,value,_template,query,matchInfo){if((prop==="content"||prop==="desc")&&query&&typeof value==="string"){if(matchInfo&&matchInfo.length>0){const highlighted=highlightWithMatchInfo(value,matchInfo,highlightOptions);return highlighted!==value?highlighted:void 0}}return void 0}}const defaultHighlightTemplateMiddleware=createHighlightTemplateMiddleware();function SimpleJekyllSearch(options2){const instance=new SimpleJekyllSearch$1;return instance.init(options2)}if(typeof window!=="undefined"){window.SimpleJekyllSearch=SimpleJekyllSearch;window.SimpleJekyllSearch.createHighlightMiddleware=createHighlightMiddleware;window.SimpleJekyllSearch.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware;window.SimpleJekyllSearch.highlightText=highlightText;window.SimpleJekyllSearch.defaultHighlightMiddleware=defaultHighlightMiddleware;window.SimpleJekyllSearch.defaultHighlightTemplateMiddleware=defaultHighlightTemplateMiddleware}exports2.createHighlightMiddleware=createHighlightMiddleware;exports2.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware;exports2.default=SimpleJekyllSearch;exports2.defaultHighlightMiddleware=defaultHighlightMiddleware;exports2.defaultHighlightTemplateMiddleware=defaultHighlightTemplateMiddleware;exports2.highlightText=highlightText;Object.defineProperties(exports2,{__esModule:{value:true},[Symbol.toStringTag]:{value:"Module"}})}));
\ No newline at end of file
diff --git a/docs/_includes/search.html b/docs/_includes/search.html
index a9e5150..c5c2b65 100644
--- a/docs/_includes/search.html
+++ b/docs/_includes/search.html
@@ -7,11 +7,20 @@