Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ 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: `'<li><a href="{{ site.url }}{url}">{title}</a></li>'` |
| `templateMiddleware` | Function | No | A function that processes template placeholders and can include highlighting functionality. The function receives (prop, value, template, query) parameters. |

## Middleware

Expand Down Expand Up @@ -139,3 +140,71 @@ SimpleJekyllSearch({
},
})
```

### templateMiddleware with Highlighting (Function) [optional]

The `templateMiddleware` function now supports highlighting functionality. It processes template placeholders and can add HTML highlighting to search results.

The function receives four parameters:
- `prop`: The property name being processed (e.g., 'title', 'content', 'desc')
- `value`: The value of the property
- `template`: The full template string
- `query`: The search query (optional)

Example with built-in highlight template middleware:

```js
import { createHighlightTemplateMiddleware } from 'simple-jekyll-search';

SimpleJekyllSearch({
// ...other config
templateMiddleware: createHighlightTemplateMiddleware({
highlightClass: 'my-highlight', // Custom CSS class
contextBefore: 30, // Characters before match
contextAfter: 30, // Characters after match
maxLength: 200, // Maximum total length
ellipsis: '...' // Text to show when truncated
}),
})
```

You can also create a custom template middleware with highlighting:

```js
SimpleJekyllSearch({
// ...other config
templateMiddleware: function(prop, value, template, query) {
// Only highlight content and desc fields
if ((prop === 'content' || prop === 'desc') && query && typeof value === 'string') {
return value.replace(
new RegExp(`(${query})`, 'gi'),
'<span class="highlight">$1</span>'
);
}
return undefined; // Use default value for other properties
},
})
```

The template middleware should return a string to replace the placeholder, or `undefined` to use the default value.

#### Search Strategy Compatibility

The highlight template middleware works with all search strategies:

- **Literal Search**: Highlights exact word matches
- **Wildcard Search**: Highlights exact matches (supports regex patterns)
- **Fuzzy Search**: Highlights both exact matches and fuzzy matches (e.g., "test" matches "tst", "testing")

The middleware intelligently handles different search types:
1. First tries exact word matching (for literal and wildcard searches)
2. Falls back to fuzzy matching if no exact matches found
3. Prefers exact matches over fuzzy matches when both are available

Example with fuzzy search:
```js
// Searching for "test" will highlight:
// - "test" (exact match)
// - "testing" (fuzzy match - highlights "test" part)
// - "tst" (fuzzy match - highlights "tst")
```
4 changes: 3 additions & 1 deletion cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ export default defineConfig({
on('before:run', (details) => {
console.log('🚀 Starting Cypress tests:', details.specs?.length || 0, 'spec(s) to run');
console.log(`Running on: ${details.browser?.name} ${details.browser?.version}`);
console.log('📝 Make sure Jekyll server is running at http://localhost:4000/Simple-Jekyll-Search/');
console.log('💡 Run: cd docs && bundle exec jekyll serve --baseurl /Simple-Jekyll-Search');
return Promise.resolve();
});

on('after:run', (_results) => {
console.log('✅ Cypress test run completed!');
console.log('✅ Cypress test run completed!');
return Promise.resolve();
});
},
Expand Down
247 changes: 240 additions & 7 deletions dest/simple-jekyll-search.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,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
);
}
}
Expand Down Expand Up @@ -157,7 +154,7 @@
success: function() {
},
searchResultTemplate: '<li><a href="{url}" title="{desc}">{title}</a></li>',
templateMiddleware: (_prop, _value, _template) => void 0,
templateMiddleware: (_prop, _value, _template, _query) => void 0,
sortMiddleware: NoSort,
noResultsText: "No results found",
limit: 10,
Expand Down Expand Up @@ -282,9 +279,9 @@
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 value = options.middleware(prop, data[prop], options.template, query);
if (typeof value !== "undefined") {
return value;
}
Expand Down Expand Up @@ -357,7 +354,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);
Expand Down Expand Up @@ -392,13 +389,249 @@
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 + `<span class="${highlightClass}">${matchText}</span>` + 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 += `<span class="${highlightClass}">${matchText}</span>`;
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 findFuzzyMatches(text, pattern) {
const matches = [];
const lowerText = text.toLowerCase();
const lowerPattern = pattern.toLowerCase().trim();
if (lowerPattern.length === 0) return matches;
const patternWords = lowerPattern.split(/\s+/);
for (const word of patternWords) {
if (word.length === 0) continue;
const wordMatches = findFuzzyWordMatches(lowerText, word);
matches.push(...wordMatches);
}
return matches;
}
function findFuzzyWordMatches(text, word) {
const matches = [];
const exactRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "gi");
let match;
while ((match = exactRegex.exec(text)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length,
text: match[0]
});
}
if (matches.length === 0) {
for (let i = 0; i < text.length; i++) {
const fuzzyMatch = findFuzzySequenceMatch(text, word, i);
if (fuzzyMatch) {
matches.push(fuzzyMatch);
i = fuzzyMatch.end - 1;
}
}
}
return matches;
}
function findFuzzySequenceMatch(text, pattern, startPos) {
let textIndex = startPos;
let patternIndex = 0;
let matchStart = -1;
let maxGap = 3;
const matchedPositions = [];
while (textIndex < text.length && patternIndex < pattern.length) {
if (text[textIndex] === pattern[patternIndex]) {
if (matchStart === -1) {
matchStart = textIndex;
}
matchedPositions.push(textIndex);
patternIndex++;
} else if (matchStart !== -1) {
if (textIndex - matchStart > maxGap * pattern.length) {
return null;
}
}
textIndex++;
}
if (patternIndex === pattern.length && matchStart !== -1 && matchedPositions.length > 0) {
const actualStart = matchedPositions[0];
const actualEnd = matchedPositions[matchedPositions.length - 1] + 1;
return {
start: actualStart,
end: actualEnd,
text: text.substring(actualStart, actualEnd)
};
}
return null;
}
function createHighlightTemplateMiddleware(options2 = {}) {
const highlightOptions = {
highlightClass: "sjs-highlight",
...options2
};
return function(prop, value, _template, query) {
if ((prop === "content" || prop === "desc") && query && typeof value === "string") {
let highlightedText = value;
let hasMatches = false;
const searchTerms = query.trim().split(/\s+/).filter((term) => term.length > 0);
for (const term of searchTerms) {
const regex = new RegExp(`\\b(${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})\\b`, "gi");
if (regex.test(highlightedText)) {
highlightedText = highlightedText.replace(regex, `<span class="${highlightOptions.highlightClass}">$1</span>`);
hasMatches = true;
}
}
if (!hasMatches) {
const fuzzyMatches = findFuzzyMatches(value, query);
if (fuzzyMatches.length > 0) {
fuzzyMatches.sort((a, b) => b.start - a.start);
for (const match of fuzzyMatches) {
const before = highlightedText.substring(0, match.start);
const after = highlightedText.substring(match.end);
const matchText = highlightedText.substring(match.start, match.end);
highlightedText = before + `<span class="${highlightOptions.highlightClass}">${matchText}</span>` + after;
}
hasMatches = true;
}
}
if (hasMatches) {
return highlightedText;
}
}
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" } });
});
Loading