From 84c729078c9512d3b6ae40791c6c08ae2c6d73fb Mon Sep 17 00:00:00 2001 From: sylhare Date: Wed, 15 Oct 2025 10:05:58 -0400 Subject: [PATCH 01/18] Separate wildcard from Levenshtein fallback --- src/SearchStrategies/search/wildcardSearch.ts | 9 ++------- tests/Repository.test.ts | 2 +- tests/SearchStrategies/wildcardSearch.test.ts | 11 ++++++----- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/SearchStrategies/search/wildcardSearch.ts b/src/SearchStrategies/search/wildcardSearch.ts index 8228475..f302888 100644 --- a/src/SearchStrategies/search/wildcardSearch.ts +++ b/src/SearchStrategies/search/wildcardSearch.ts @@ -1,17 +1,12 @@ -import { levenshteinSearch } from './levenshtein'; - /** * Matches a pattern with wildcards (*) against a text. * * @param text - The text to search in * @param pattern - The pattern to search for (supports * as a wildcard for unknown characters) - * @returns true if matches, false otherwise + * @returns true if the pattern matches, false otherwise */ export function wildcardSearch(text: string, pattern: string): boolean { const regexPattern = pattern.replace(/\*/g, '.*'); const regex = new RegExp(`^${regexPattern}$`, 'i'); - - if (regex.test(text)) return true; - - return levenshteinSearch(text, pattern); + return regex.test(text); } \ No newline at end of file diff --git a/tests/Repository.test.ts b/tests/Repository.test.ts index 10bd50a..e8b7e25 100644 --- a/tests/Repository.test.ts +++ b/tests/Repository.test.ts @@ -50,7 +50,7 @@ describe('Repository', () => { it('finds items using a wildcard pattern', () => { repository.setOptions({ strategy: 'wildcard' }); - expect(repository.search('* ispum')).toEqual([loremElement]); + expect(repository.search('* ipsum')).toEqual([loremElement]); expect(repository.search('*bar')).toEqual([barElement, almostBarElement]); }); diff --git a/tests/SearchStrategies/wildcardSearch.test.ts b/tests/SearchStrategies/wildcardSearch.test.ts index 5dc8883..2a4a83b 100644 --- a/tests/SearchStrategies/wildcardSearch.test.ts +++ b/tests/SearchStrategies/wildcardSearch.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { wildcardSearch } from '../../src/SearchStrategies/search/wildcardSearch'; -describe('wildcardFuzzySearch', () => { +describe('wildcardSearch', () => { it('should return true for exact matches', () => { expect(wildcardSearch('hello', 'hello')).toBe(true); }); @@ -19,12 +19,13 @@ describe('wildcardFuzzySearch', () => { expect(wildcardSearch('hello world', 'hello*')).toBe(true); }); - it('should return true for fuzzy matches with high similarity', () => { - expect(wildcardSearch('hello', 'helo')).toBe(true); // 80% similarity - expect(wildcardSearch('hello', 'hell')).toBe(true); // 80% similarity + // Wildcard search does not support fuzzy matching + it.skip('should return true for fuzzy matches with high similarity', () => { + expect(wildcardSearch('hello', 'helo')).toBe(true); + expect(wildcardSearch('hello', 'hell')).toBe(true); }); - it('should return false for matches below the similarity threshold', () => { + it('should return false for non-matching wildcard patterns', () => { expect(wildcardSearch('world', 'h*o')).toBe(false); expect(wildcardSearch('xyz', 'abc')).toBe(false); }); From ef08fe7ff77cc25db1ee46b83ec2c4b4f64f398f Mon Sep 17 00:00:00 2001 From: sylhare Date: Wed, 15 Oct 2025 10:28:57 -0400 Subject: [PATCH 02/18] Update search strategy with findMatches --- src/Repository.ts | 16 ++- src/SearchStrategies/SearchStrategy.ts | 38 +++++-- src/SearchStrategies/search/findMatches.ts | 81 +++++++++++++++ src/SearchStrategies/types.ts | 27 ++++- src/index.ts | 1 + src/utils/types.ts | 1 + tests/Repository.test.ts | 49 +++++++-- tests/SearchStrategies/findMatches.test.ts | 113 +++++++++++++++++++++ 8 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 src/SearchStrategies/search/findMatches.ts create mode 100644 tests/SearchStrategies/findMatches.test.ts diff --git a/src/Repository.ts b/src/Repository.ts index de6b96c..0527cee 100644 --- a/src/Repository.ts +++ b/src/Repository.ts @@ -73,12 +73,24 @@ export class Repository { } private findMatchesInObject(obj: RepositoryData, criteria: string): RepositoryData | undefined { + 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; + + if (this.options.searchStrategy.findMatches) { + const matchInfo = this.options.searchStrategy.findMatches(obj[key], criteria); + if (matchInfo && matchInfo.length > 0) { + result._matchInfo[key] = matchInfo; + } + } } } - return undefined; + + return hasMatch ? result : undefined; } private isExcluded(term: any): boolean { diff --git a/src/SearchStrategies/SearchStrategy.ts b/src/SearchStrategies/SearchStrategy.ts index e51f266..4bbbd84 100644 --- a/src/SearchStrategies/SearchStrategy.ts +++ b/src/SearchStrategies/SearchStrategy.ts @@ -1,12 +1,36 @@ import { fuzzySearch } from './search/fuzzySearch'; import { literalSearch } from './search/literalSearch'; import { wildcardSearch } from './search/wildcardSearch'; +import { findLiteralMatches, findFuzzyMatches, findWildcardMatches } from './search/findMatches'; import { SearchStrategy } from './types'; -export const LiteralSearchStrategy = new SearchStrategy(literalSearch); -export const FuzzySearchStrategy = new SearchStrategy((text: string, criteria: string) => { - return fuzzySearch(text, criteria) || literalSearch(text, criteria); -}); -export const WildcardSearchStrategy = new SearchStrategy((text: string, criteria: string) => { - return wildcardSearch(text, criteria) || literalSearch(text, criteria); -}); \ No newline at end of file +export const LiteralSearchStrategy = new SearchStrategy( + literalSearch, + findLiteralMatches +); + +export const FuzzySearchStrategy = new SearchStrategy( + (text: string, criteria: string) => { + return fuzzySearch(text, criteria) || literalSearch(text, criteria); + }, + (text: string, criteria: string) => { + const fuzzyMatches = findFuzzyMatches(text, criteria); + if (fuzzyMatches.length > 0) { + return fuzzyMatches; + } + return findLiteralMatches(text, criteria); + } +); + +export const WildcardSearchStrategy = new SearchStrategy( + (text: string, criteria: string) => { + return wildcardSearch(text, criteria) || literalSearch(text, criteria); + }, + (text: string, criteria: string) => { + const wildcardMatches = findWildcardMatches(text, criteria); + if (wildcardMatches.length > 0) { + return wildcardMatches; + } + return findLiteralMatches(text, criteria); + } +); \ No newline at end of file diff --git a/src/SearchStrategies/search/findMatches.ts b/src/SearchStrategies/search/findMatches.ts new file mode 100644 index 0000000..bac843f --- /dev/null +++ b/src/SearchStrategies/search/findMatches.ts @@ -0,0 +1,81 @@ +import { MatchInfo } from '../types'; + +export function findLiteralMatches(text: string, criteria: string): MatchInfo[] { + const matches: MatchInfo[] = []; + const lowerText = text.toLowerCase(); + const lowerCriteria = criteria.toLowerCase(); + + let startIndex = 0; + while ((startIndex = lowerText.indexOf(lowerCriteria, startIndex)) !== -1) { + matches.push({ + start: startIndex, + end: startIndex + criteria.length, + text: text.substring(startIndex, startIndex + criteria.length), + type: 'exact' + }); + startIndex += criteria.length; + } + + return matches; +} + +export function findFuzzyMatches(text: string, criteria: string): MatchInfo[] { + criteria = criteria.trimEnd(); + if (criteria.length === 0) return []; + + const lowerText = text.toLowerCase(); + const lowerCriteria = criteria.toLowerCase(); + + let textIndex = 0; + let criteriaIndex = 0; + const matchedIndices: number[] = []; + + while (textIndex < text.length && criteriaIndex < criteria.length) { + if (lowerText[textIndex] === lowerCriteria[criteriaIndex]) { + matchedIndices.push(textIndex); + criteriaIndex++; + } + textIndex++; + } + + if (criteriaIndex !== criteria.length) { + return []; + } + + if (matchedIndices.length === 0) { + return []; + } + + const start = matchedIndices[0]; + const end = matchedIndices[matchedIndices.length - 1] + 1; + + return [{ + start, + end, + text: text.substring(start, end), + type: 'fuzzy' + }]; +} + +export function findWildcardMatches(text: string, pattern: string): MatchInfo[] { + const regexPattern = pattern.replace(/\*/g, '.*'); + const regex = new RegExp(regexPattern, 'gi'); + const matches: MatchInfo[] = []; + + let match; + while ((match = regex.exec(text)) !== null) { + matches.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + type: 'wildcard' + }); + + if (regex.lastIndex === match.index) { + regex.lastIndex++; + } + } + + return matches; +} + diff --git a/src/SearchStrategies/types.ts b/src/SearchStrategies/types.ts index c1715e0..a852725 100644 --- a/src/SearchStrategies/types.ts +++ b/src/SearchStrategies/types.ts @@ -1,12 +1,25 @@ +export interface MatchInfo { + start: number; + end: number; + text: string; + type: 'exact' | 'fuzzy' | 'wildcard'; +} + export interface Matcher { matches(text: string | null, criteria: string): boolean; + findMatches?(text: string | null, criteria: string): MatchInfo[]; } export class SearchStrategy implements Matcher { private readonly matchFunction: (text: string, criteria: string) => boolean; + private readonly findMatchesFunction?: (text: string, criteria: string) => MatchInfo[]; - constructor(matchFunction: (text: string, criteria: string) => boolean) { + constructor( + matchFunction: (text: string, criteria: string) => boolean, + findMatchesFunction?: (text: string, criteria: string) => MatchInfo[] + ) { this.matchFunction = matchFunction; + this.findMatchesFunction = findMatchesFunction; } matches(text: string | null, criteria: string): boolean { @@ -16,4 +29,16 @@ export class SearchStrategy implements Matcher { return this.matchFunction(text, criteria); } + + findMatches(text: string | null, criteria: string): MatchInfo[] { + if (text === null || text.trim() === '' || !criteria) { + return []; + } + + if (this.findMatchesFunction) { + return this.findMatchesFunction(text, criteria); + } + + return []; + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c2ba8af..0d80fe4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ function SimpleJekyllSearch(options: SearchOptions): SimpleJekyllSearchInstance } export default SimpleJekyllSearch; +export type { MatchInfo } from './SearchStrategies/types'; // Add to window if in browser environment if (typeof window !== 'undefined') { diff --git a/src/utils/types.ts b/src/utils/types.ts index 6953478..1532a64 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -27,6 +27,7 @@ export interface RepositoryOptions { export interface RepositoryData { [key: string]: any; + _matchInfo?: Record; } export interface SearchOptions extends Omit { diff --git a/tests/Repository.test.ts b/tests/Repository.test.ts index e8b7e25..efa91f7 100644 --- a/tests/Repository.test.ts +++ b/tests/Repository.test.ts @@ -26,32 +26,55 @@ describe('Repository', () => { }); it('finds a simple string', () => { - expect(repository.search('bar')).toEqual([barElement, almostBarElement]); + const results = repository.search('bar'); + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject(barElement); + expect(results[1]).toMatchObject(almostBarElement); + expect(results[0]._matchInfo).toBeDefined(); }); it('limits the search results to one even if found more', () => { repository.setOptions({ limit: 1 }); - expect(repository.search('bar')).toEqual([barElement]); + const results = repository.search('bar'); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject(barElement); + expect(results[0]._matchInfo).toBeDefined(); }); it('finds a long string', () => { - expect(repository.search('lorem ipsum')).toEqual([loremElement]); + const results = repository.search('lorem ipsum'); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject(loremElement); + expect(results[0]._matchInfo).toBeDefined(); }); it('[deprecated] finds a fuzzy string', () => { repository.setOptions({ fuzzy: true }); - expect(repository.search('lrm ism')).toEqual([loremElement]); + const results = repository.search('lrm ism'); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject(loremElement); + expect(results[0]._matchInfo).toBeDefined(); }); it('finds a fuzzy string', () => { repository.setOptions({ strategy: 'fuzzy' }); - expect(repository.search('lrm ism')).toEqual([loremElement]); + const results = repository.search('lrm ism'); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject(loremElement); + expect(results[0]._matchInfo).toBeDefined(); }); it('finds items using a wildcard pattern', () => { repository.setOptions({ strategy: 'wildcard' }); - expect(repository.search('* ipsum')).toEqual([loremElement]); - expect(repository.search('*bar')).toEqual([barElement, almostBarElement]); + const results1 = repository.search('* ipsum'); + expect(results1).toHaveLength(1); + expect(results1[0]).toMatchObject(loremElement); + expect(results1[0]._matchInfo).toBeDefined(); + + const results2 = repository.search('*bar'); + expect(results2).toHaveLength(2); + expect(results2[0]).toMatchObject(barElement); + expect(results2[1]).toMatchObject(almostBarElement); }); it('returns empty search results when an empty criteria is provided', () => { @@ -71,7 +94,11 @@ describe('Repository', () => { return a.title.localeCompare(b.title); }, }); - expect(repository.search('r')).toEqual([almostBarElement, barElement, loremElement]); + const results = repository.search('r'); + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject(almostBarElement); + expect(results[1]).toMatchObject(barElement); + expect(results[2]).toMatchObject(loremElement); }); it('search results should be a clone and not a reference to repository data', () => { @@ -82,13 +109,15 @@ describe('Repository', () => { ); const results = repository.search(query); - expect(results).toEqual([{ name: 'Alice', role: 'Developer' }]); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ name: 'Alice', role: 'Developer' }); (results as SearchResult[]).forEach(result => { result.role = 'Modified Role'; }); const originalData = repository.search(query); - expect(originalData).toEqual([{ name: 'Alice', role: 'Developer' }]); + expect(originalData).toHaveLength(1); + expect(originalData[0]).toMatchObject({ name: 'Alice', role: 'Developer' }); }); }); \ No newline at end of file diff --git a/tests/SearchStrategies/findMatches.test.ts b/tests/SearchStrategies/findMatches.test.ts new file mode 100644 index 0000000..5d218fa --- /dev/null +++ b/tests/SearchStrategies/findMatches.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { findLiteralMatches, findFuzzyMatches, findWildcardMatches } from '../../src/SearchStrategies/search/findMatches'; + +describe('findMatches Functions', () => { + describe('findLiteralMatches', () => { + it('should find all occurrences of a pattern', () => { + const result = findLiteralMatches('hello world hello', 'hello'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + start: 0, + end: 5, + text: 'hello', + type: 'exact' + }); + expect(result[1]).toEqual({ + start: 12, + end: 17, + text: 'hello', + type: 'exact' + }); + }); + + it('should handle case insensitive matching', () => { + const result = findLiteralMatches('Hello World', 'hello'); + expect(result).toHaveLength(1); + expect(result[0].text).toBe('Hello'); + expect(result[0].type).toBe('exact'); + }); + + it('should return empty array for no matches', () => { + const result = findLiteralMatches('hello world', 'xyz'); + expect(result).toEqual([]); + }); + + it('should find overlapping patterns', () => { + const result = findLiteralMatches('aaaa', 'aa'); + expect(result).toHaveLength(2); + }); + }); + + describe('findFuzzyMatches', () => { + it('should find fuzzy character sequence match', () => { + const result = findFuzzyMatches('JavaScript', 'java'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('fuzzy'); + expect(result[0].text).toBe('Java'); + }); + + it('should handle character sequence matching', () => { + const result = findFuzzyMatches('hello world', 'hlowrd'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('fuzzy'); + }); + + it('should return empty array for no match', () => { + const result = findFuzzyMatches('hello', 'xyz'); + expect(result).toEqual([]); + }); + + it('should handle empty pattern', () => { + const result = findFuzzyMatches('hello', ''); + expect(result).toEqual([]); + }); + + it('should trim trailing spaces from pattern', () => { + const result = findFuzzyMatches('hello', 'hlo '); + expect(result).toHaveLength(1); + }); + }); + + describe('findWildcardMatches', () => { + it('should find wildcard pattern matches', () => { + const result = findWildcardMatches('hello world', 'hel*world'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('wildcard'); + expect(result[0].text).toBe('hello world'); + }); + + it('should find multiple wildcard matches', () => { + const result = findWildcardMatches('test test test', 'te*t'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('wildcard'); + }); + + it('should return empty array for no matches', () => { + const result = findWildcardMatches('hello', 'xyz*'); + expect(result).toEqual([]); + }); + + it('should handle simple wildcard patterns', () => { + const result = findWildcardMatches('hello', 'hel*'); + expect(result).toHaveLength(1); + expect(result[0].text).toBe('hello'); + }); + }); + + describe('Consistency between boolean and findMatches functions', () => { + it('should be consistent for literal search', () => { + const text = 'hello world'; + const criteria = 'world'; + const matches = findLiteralMatches(text, criteria); + expect(matches.length > 0).toBe(true); + }); + + it('should be consistent for fuzzy search', () => { + const text = 'JavaScript'; + const criteria = 'java'; + const matches = findFuzzyMatches(text, criteria); + expect(matches.length > 0).toBe(true); + }); + }); +}); + From 9e8a96ba6d693322ab973c7506a406512b2e1e97 Mon Sep 17 00:00:00 2001 From: sylhare Date: Wed, 15 Oct 2025 13:47:16 -0400 Subject: [PATCH 03/18] Add highlighting middleware --- src/SimpleJekyllSearch.ts | 2 +- src/Templater.ts | 30 ++- src/index.ts | 5 + src/middleware/highlightMiddleware.ts | 39 ++++ src/middleware/highlighting.ts | 117 ++++++++++ src/utils/types.ts | 8 +- tests/Templater.test.ts | 78 +++++++ tests/middleware/highlightMiddleware.test.ts | 214 +++++++++++++++++++ tests/middleware/highlighting.test.ts | 200 +++++++++++++++++ 9 files changed, 689 insertions(+), 4 deletions(-) create mode 100644 src/middleware/highlightMiddleware.ts create mode 100644 src/middleware/highlighting.ts create mode 100644 tests/middleware/highlightMiddleware.test.ts create mode 100644 tests/middleware/highlighting.test.ts diff --git a/src/SimpleJekyllSearch.ts b/src/SimpleJekyllSearch.ts index c7822b4..2dd9400 100644 --- a/src/SimpleJekyllSearch.ts +++ b/src/SimpleJekyllSearch.ts @@ -84,7 +84,7 @@ class SimpleJekyllSearch { results.forEach(result => { result.query = query; const div = document.createElement('div'); - div.innerHTML = compileTemplate(result); + div.innerHTML = compileTemplate(result, query); fragment.appendChild(div); }); diff --git a/src/Templater.ts b/src/Templater.ts index cb721ff..f925fd9 100644 --- a/src/Templater.ts +++ b/src/Templater.ts @@ -1,4 +1,12 @@ -type MiddlewareFunction = (prop: string, value: any, template: string) => any; +import { MatchInfo } from './SearchStrategies/types'; + +type MiddlewareFunction = ( + prop: string, + value: any, + template: string, + query?: string, + matchInfo?: MatchInfo[] +) => any; interface TemplaterOptions { pattern?: RegExp; @@ -8,6 +16,7 @@ interface TemplaterOptions { interface Data { [key: string]: any; + _matchInfo?: Record; } const options: TemplaterOptions & { pattern: RegExp; template: string; middleware: MiddlewareFunction } = { @@ -28,12 +37,29 @@ export function setOptions(_options: TemplaterOptions): void { } } -export function compile(data: Data): string { +export function compile(data: Data, query?: string): string { return options.template.replace(options.pattern, function(match: string, prop: string) { + const matchInfo = data._matchInfo?.[prop]; + + if (matchInfo && matchInfo.length > 0 && query) { + const value = options.middleware(prop, data[prop], options.template, query, matchInfo); + if (typeof value !== 'undefined') { + return value; + } + } + + if (query) { + const value = options.middleware(prop, data[prop], options.template, query); + if (typeof value !== 'undefined') { + return value; + } + } + const value = options.middleware(prop, data[prop], options.template); if (typeof value !== 'undefined') { return value; } + return data[prop] || match; }); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0d80fe4..60347a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import SimpleJekyllSearchClass from './SimpleJekyllSearch'; import { SearchOptions, SimpleJekyllSearchInstance } from './utils/types'; +import { createHighlightTemplateMiddleware } from './middleware/highlightMiddleware'; function SimpleJekyllSearch(options: SearchOptions): SimpleJekyllSearchInstance { const instance = new SimpleJekyllSearchClass(); @@ -8,8 +9,12 @@ function SimpleJekyllSearch(options: SearchOptions): SimpleJekyllSearchInstance export default SimpleJekyllSearch; export type { MatchInfo } from './SearchStrategies/types'; +export type { HighlightOptions } from './middleware/highlighting'; +export { highlightWithMatchInfo, escapeHtml, mergeOverlappingMatches } from './middleware/highlighting'; +export { createHighlightTemplateMiddleware, defaultHighlightMiddleware } from './middleware/highlightMiddleware'; // Add to window if in browser environment if (typeof window !== 'undefined') { (window as any).SimpleJekyllSearch = SimpleJekyllSearch; + (window as any).createHighlightTemplateMiddleware = createHighlightTemplateMiddleware; } diff --git a/src/middleware/highlightMiddleware.ts b/src/middleware/highlightMiddleware.ts new file mode 100644 index 0000000..46e74d5 --- /dev/null +++ b/src/middleware/highlightMiddleware.ts @@ -0,0 +1,39 @@ +import { MatchInfo } from '../SearchStrategies/types'; +import { highlightWithMatchInfo, HighlightOptions } from './highlighting'; + +export function createHighlightTemplateMiddleware(options: HighlightOptions = {}) { + const highlightOptions: HighlightOptions = { + className: options.className || 'search-highlight', + maxLength: options.maxLength, + contextLength: options.contextLength || 30 + }; + + return function( + prop: string, + value: string, + _template: string, + query?: string, + matchInfo?: MatchInfo[] + ): string | undefined { + if ((prop === 'content' || prop === 'desc' || prop === 'description') && query && typeof value === 'string') { + if (matchInfo && matchInfo.length > 0) { + const highlighted = highlightWithMatchInfo(value, matchInfo, highlightOptions); + return highlighted !== value ? highlighted : undefined; + } + } + + return undefined; + }; +} + +export function defaultHighlightMiddleware( + prop: string, + value: string, + template: string, + query?: string, + matchInfo?: MatchInfo[] +): string | undefined { + const middleware = createHighlightTemplateMiddleware(); + return middleware(prop, value, template, query, matchInfo); +} + diff --git a/src/middleware/highlighting.ts b/src/middleware/highlighting.ts new file mode 100644 index 0000000..da84be1 --- /dev/null +++ b/src/middleware/highlighting.ts @@ -0,0 +1,117 @@ +import { MatchInfo } from '../SearchStrategies/types'; + +export interface HighlightOptions { + className?: string; + maxLength?: number; + contextLength?: number; +} + +export function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} + +export function mergeOverlappingMatches(matches: MatchInfo[]): MatchInfo[] { + if (matches.length === 0) return []; + + const sorted = [...matches].sort((a, b) => a.start - b.start); + const merged: MatchInfo[] = [{ ...sorted[0] }]; + + for (let i = 1; i < sorted.length; i++) { + const current = sorted[i]; + const last = merged[merged.length - 1]; + + if (current.start <= last.end) { + last.end = Math.max(last.end, current.end); + } else { + merged.push({ ...current }); + } + } + + return merged; +} + +export function highlightWithMatchInfo( + text: string, + matchInfo: MatchInfo[], + options: HighlightOptions = {} +): string { + if (!text || matchInfo.length === 0) { + return escapeHtml(text); + } + + const className = options.className || 'search-highlight'; + const maxLength = options.maxLength; + + const mergedMatches = mergeOverlappingMatches(matchInfo); + + let result = ''; + let lastIndex = 0; + + for (const match of mergedMatches) { + result += escapeHtml(text.substring(lastIndex, match.start)); + result += `${escapeHtml(text.substring(match.start, match.end))}`; + lastIndex = match.end; + } + + result += escapeHtml(text.substring(lastIndex)); + + if (maxLength && result.length > maxLength) { + result = truncateAroundMatches(text, mergedMatches, maxLength, options.contextLength || 30, className); + } + + return result; +} + +function truncateAroundMatches( + text: string, + matches: MatchInfo[], + maxLength: number, + contextLength: number, + className: string +): string { + if (matches.length === 0) { + const truncated = text.substring(0, maxLength - 3); + return escapeHtml(truncated) + '...'; + } + + const firstMatch = matches[0]; + const start = Math.max(0, firstMatch.start - contextLength); + const end = Math.min(text.length, firstMatch.end + contextLength); + + let result = ''; + + if (start > 0) { + result += '...'; + } + + const snippet = text.substring(start, end); + const adjustedMatches = matches + .filter(m => m.start < end && m.end > start) + .map(m => ({ + ...m, + start: Math.max(0, m.start - start), + end: Math.min(snippet.length, m.end - start) + })); + + let lastIndex = 0; + for (const match of adjustedMatches) { + result += escapeHtml(snippet.substring(lastIndex, match.start)); + result += `${escapeHtml(snippet.substring(match.start, match.end))}`; + lastIndex = match.end; + } + result += escapeHtml(snippet.substring(lastIndex)); + + if (end < text.length) { + result += '...'; + } + + return result; +} + diff --git a/src/utils/types.ts b/src/utils/types.ts index 1532a64..6fbc6a5 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -36,7 +36,13 @@ export interface SearchOptions extends Omit json: SearchData[] | string; success?: (this: { search: (query: string) => void }) => void; searchResultTemplate?: string; - templateMiddleware?: (prop: string, value: string, template: string) => string | undefined; + templateMiddleware?: ( + prop: string, + value: string, + template: string, + query?: string, + matchInfo?: import('../SearchStrategies/types').MatchInfo[] + ) => string | undefined; noResultsText?: string; debounceTime?: number | null; onSearch?: () => void; diff --git a/tests/Templater.test.ts b/tests/Templater.test.ts index fb2feed..a0472ac 100644 --- a/tests/Templater.test.ts +++ b/tests/Templater.test.ts @@ -59,4 +59,82 @@ describe('Templater', () => { expect(compiled).toBe('foo - leading/slash'); }); + + it('compile accepts optional query parameter', () => { + templater.setOptions({ + template: '{foo}', + middleware(_prop: string, value: string, _template: string, query?: string) { + if (query) { + return `${value} (query: ${query})`; + } + return value; + } + }); + + const compiled = templater.compile({ foo: 'bar' }, 'test'); + expect(compiled).toBe('bar (query: test)'); + }); + + it('middleware receives matchInfo when available', () => { + templater.setOptions({ + template: '{desc}', + middleware(_prop: string, value: string, _template: string, _query?: string, matchInfo?: any[]) { + if (matchInfo && matchInfo.length > 0) { + return `${value} [${matchInfo.length} matches]`; + } + return value; + } + }); + + const data = { + desc: 'hello world', + _matchInfo: { + desc: [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ] + } + }; + + const compiled = templater.compile(data, 'hello'); + expect(compiled).toBe('hello world [1 matches]'); + }); + + it('middleware maintains backward compatibility with 3 parameters', () => { + templater.setOptions({ + template: '{foo}', + middleware(_prop: string, value: string) { + return value.toUpperCase(); + } + }); + + const compiled = templater.compile({ foo: 'bar' }, 'query'); + expect(compiled).toBe('BAR'); + }); + + it('middleware receives query but not matchInfo when matchInfo is unavailable', () => { + templater.setOptions({ + template: '{foo}', + middleware(_prop: string, value: string, _template: string, query?: string, matchInfo?: any[]) { + if (query && !matchInfo) { + return `${value} (query: ${query}, no matches)`; + } + return value; + } + }); + + const compiled = templater.compile({ foo: 'bar' }, 'test'); + expect(compiled).toBe('bar (query: test, no matches)'); + }); + + it('compile works without query parameter (backward compatible)', () => { + templater.setOptions({ + template: '{foo}', + middleware(_prop: string, value: string) { + return value; + } + }); + + const compiled = templater.compile({ foo: 'bar' }); + expect(compiled).toBe('bar'); + }); }); \ No newline at end of file diff --git a/tests/middleware/highlightMiddleware.test.ts b/tests/middleware/highlightMiddleware.test.ts new file mode 100644 index 0000000..49eb460 --- /dev/null +++ b/tests/middleware/highlightMiddleware.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; +import { + createHighlightTemplateMiddleware, + defaultHighlightMiddleware +} from '../../src/middleware/highlightMiddleware'; +import { MatchInfo } from '../../src/SearchStrategies/types'; + +describe('createHighlightTemplateMiddleware', () => { + it('should create a middleware function', () => { + const middleware = createHighlightTemplateMiddleware(); + expect(typeof middleware).toBe('function'); + }); + + it('should highlight content field when matchInfo is provided', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + + const result = middleware('content', 'hello world', '
{content}
', 'hello', matchInfo); + + expect(result).toBeDefined(); + expect(result).toContain('hello'); + expect(result).toContain('world'); + }); + + it('should highlight desc field when matchInfo is provided', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 6, end: 11, text: 'world', type: 'exact' } + ]; + + const result = middleware('desc', 'hello world', '
{desc}
', 'world', matchInfo); + + expect(result).toBeDefined(); + expect(result).toContain('world'); + }); + + it('should highlight description field when matchInfo is provided', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 4, text: 'test', type: 'exact' } + ]; + + const result = middleware('description', 'test data', '
{description}
', 'test', matchInfo); + + expect(result).toBeDefined(); + expect(result).toContain('test'); + }); + + it('should return undefined for non-content fields', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'title', type: 'exact' } + ]; + + const result = middleware('title', 'title text', '
{title}
', 'title', matchInfo); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when query is not provided', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + + const result = middleware('content', 'hello world', '
{content}
', undefined, matchInfo); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when matchInfo is empty', () => { + const middleware = createHighlightTemplateMiddleware(); + + const result = middleware('content', 'hello world', '
{content}
', 'hello', []); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when matchInfo is not provided', () => { + const middleware = createHighlightTemplateMiddleware(); + + const result = middleware('content', 'hello world', '
{content}
', 'hello', undefined); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when value is not a string', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 1, text: '1', type: 'exact' } + ]; + + const result = middleware('content', 123 as any, '
{content}
', '1', matchInfo); + + expect(result).toBeUndefined(); + }); + + it('should use custom className option', () => { + const middleware = createHighlightTemplateMiddleware({ + className: 'custom-highlight' + }); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + + const result = middleware('content', 'hello world', '
{content}
', 'hello', matchInfo); + + expect(result).toContain('hello'); + }); + + it('should respect maxLength option', () => { + const middleware = createHighlightTemplateMiddleware({ + maxLength: 50 + }); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 4, text: 'test', type: 'exact' } + ]; + const longText = 'test this is a very long text that should be truncated because it exceeds the maximum allowed length'; + + const result = middleware('content', longText, '
{content}
', 'test', matchInfo); + + expect(result).toBeDefined(); + expect(result).toContain('...'); + expect(result).toContain('test'); + }); + + it('should handle multiple matches', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 6, end: 11, text: 'world', type: 'exact' } + ]; + + const result = middleware('content', 'hello world', '
{content}
', 'hello world', matchInfo); + + expect(result).toContain('hello'); + expect(result).toContain('world'); + }); + + it('should escape HTML in highlighted text', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = [ + { start: 0, end: 6, text: '
test
', type: 'exact' } + ]; + + const result = middleware('content', '
test
', '
{content}
', 'test', matchInfo); + + expect(result).toBeDefined(); + expect(result).not.toContain('
test
'); + expect(result).toContain('<'); + expect(result).toContain('>'); + }); + + it('should return undefined when highlighted result equals original', () => { + const middleware = createHighlightTemplateMiddleware(); + const matchInfo: MatchInfo[] = []; + + const result = middleware('content', 'no matches', '
{content}
', 'test', matchInfo); + + expect(result).toBeUndefined(); + }); +}); + +describe('defaultHighlightMiddleware', () => { + it('should work as a pre-configured middleware', () => { + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + + const result = defaultHighlightMiddleware('content', 'hello world', '
{content}
', 'hello', matchInfo); + + expect(result).toBeDefined(); + expect(result).toContain('hello'); + }); + + it('should use default search-highlight class', () => { + const matchInfo: MatchInfo[] = [ + { start: 0, end: 4, text: 'test', type: 'exact' } + ]; + + const result = defaultHighlightMiddleware('desc', 'test data', '
{desc}
', 'test', matchInfo); + + expect(result).toContain('class="search-highlight"'); + }); + + it('should return undefined for non-content fields', () => { + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'title', type: 'exact' } + ]; + + const result = defaultHighlightMiddleware('title', 'title text', '
{title}
', 'title', matchInfo); + + expect(result).toBeUndefined(); + }); + + it('should return undefined without query', () => { + const matchInfo: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + + const result = defaultHighlightMiddleware('content', 'hello world', '
{content}
', undefined, matchInfo); + + expect(result).toBeUndefined(); + }); + + it('should return undefined without matchInfo', () => { + const result = defaultHighlightMiddleware('content', 'hello world', '
{content}
', 'hello', undefined); + + expect(result).toBeUndefined(); + }); +}); + diff --git a/tests/middleware/highlighting.test.ts b/tests/middleware/highlighting.test.ts new file mode 100644 index 0000000..68ca028 --- /dev/null +++ b/tests/middleware/highlighting.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { + escapeHtml, + mergeOverlappingMatches, + highlightWithMatchInfo +} from '../../src/middleware/highlighting'; +import { MatchInfo } from '../../src/SearchStrategies/types'; + +describe('escapeHtml', () => { + it('should escape HTML special characters', () => { + expect(escapeHtml('
')).toBe('<div>'); + expect(escapeHtml('&')).toBe('&'); + expect(escapeHtml('"hello"')).toBe('"hello"'); + expect(escapeHtml("'hello'")).toBe(''hello''); + }); + + it('should handle mixed content', () => { + expect(escapeHtml('')) + .toBe('<script>alert("XSS")</script>'); + }); + + it('should handle empty string', () => { + expect(escapeHtml('')).toBe(''); + }); + + it('should not escape safe text', () => { + expect(escapeHtml('hello world')).toBe('hello world'); + }); +}); + +describe('mergeOverlappingMatches', () => { + it('should return empty array for empty input', () => { + expect(mergeOverlappingMatches([])).toEqual([]); + }); + + it('should return single match unchanged', () => { + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + expect(mergeOverlappingMatches(matches)).toEqual(matches); + }); + + it('should merge overlapping matches', () => { + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 3, end: 8, text: 'lo wo', type: 'exact' } + ]; + const result = mergeOverlappingMatches(matches); + expect(result).toHaveLength(1); + expect(result[0].start).toBe(0); + expect(result[0].end).toBe(8); + }); + + it('should merge adjacent matches', () => { + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 5, end: 11, text: ' world', type: 'exact' } + ]; + const result = mergeOverlappingMatches(matches); + expect(result).toHaveLength(1); + expect(result[0].start).toBe(0); + expect(result[0].end).toBe(11); + }); + + it('should keep separate non-overlapping matches', () => { + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 10, end: 15, text: 'world', type: 'exact' } + ]; + const result = mergeOverlappingMatches(matches); + expect(result).toHaveLength(2); + expect(result[0].start).toBe(0); + expect(result[0].end).toBe(5); + expect(result[1].start).toBe(10); + expect(result[1].end).toBe(15); + }); + + it('should handle unsorted matches', () => { + const matches: MatchInfo[] = [ + { start: 10, end: 15, text: 'world', type: 'exact' }, + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + const result = mergeOverlappingMatches(matches); + expect(result).toHaveLength(2); + expect(result[0].start).toBe(0); + expect(result[1].start).toBe(10); + }); + + it('should merge multiple overlapping matches', () => { + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 3, end: 8, text: 'lo wo', type: 'exact' }, + { start: 6, end: 11, text: 'world', type: 'exact' } + ]; + const result = mergeOverlappingMatches(matches); + expect(result).toHaveLength(1); + expect(result[0].start).toBe(0); + expect(result[0].end).toBe(11); + }); +}); + +describe('highlightWithMatchInfo', () => { + it('should return escaped text with no matches', () => { + const text = 'hello world'; + const matches: MatchInfo[] = []; + expect(highlightWithMatchInfo(text, matches)).toBe('hello world'); + }); + + it('should highlight single match', () => { + const text = 'hello world'; + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('hello world'); + }); + + it('should highlight multiple matches', () => { + const text = 'hello world'; + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 6, end: 11, text: 'world', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('hello world'); + }); + + it('should use custom className', () => { + const text = 'hello world'; + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches, { className: 'custom-highlight' }); + expect(result).toBe('hello world'); + }); + + it('should escape HTML in text', () => { + const text = '
hello
'; + const matches: MatchInfo[] = [ + { start: 5, end: 10, text: 'hello', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('<div>hello</div>'); + }); + + it('should handle empty text', () => { + const text = ''; + const matches: MatchInfo[] = []; + expect(highlightWithMatchInfo(text, matches)).toBe(''); + }); + + it('should merge overlapping matches before highlighting', () => { + const text = 'hello world'; + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' }, + { start: 3, end: 8, text: 'lo wo', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('hello world'); + }); + + it('should truncate long text with maxLength option', () => { + const text = 'This is a very long text that should be truncated when it exceeds the maximum length'; + const matches: MatchInfo[] = [ + { start: 10, end: 14, text: 'very', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches, { maxLength: 50, contextLength: 10 }); + expect(result.length).toBeLessThan(text.length); + expect(result).toContain('very'); + expect(result).toContain('...'); + }); + + it('should handle match at the beginning of text', () => { + const text = 'hello world'; + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('hello world'); + }); + + it('should handle match at the end of text', () => { + const text = 'hello world'; + const matches: MatchInfo[] = [ + { start: 6, end: 11, text: 'world', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('hello world'); + }); + + it('should handle entire text as match', () => { + const text = 'hello'; + const matches: MatchInfo[] = [ + { start: 0, end: 5, text: 'hello', type: 'exact' } + ]; + const result = highlightWithMatchInfo(text, matches); + expect(result).toBe('hello'); + }); +}); + From 1b28e119f557a9e12c2fa56f37f87c76ad0c6bb2 Mon Sep 17 00:00:00 2001 From: sylhare Date: Thu, 16 Oct 2025 09:50:03 -0400 Subject: [PATCH 04/18] Add search cache --- src/SearchStrategies/SearchCache.ts | 115 +++++++++ src/SearchStrategies/search/findMatches.ts | 33 ++- src/SearchStrategies/types.ts | 67 ++++- src/utils/PerformanceMonitor.ts | 49 ++++ tests/SearchStrategies/SearchCache.test.ts | 242 ++++++++++++++++++ .../SearchStrategy.caching.test.ts | 207 +++++++++++++++ tests/performance/benchmark.test.ts | 221 ++++++++++++++++ 7 files changed, 916 insertions(+), 18 deletions(-) create mode 100644 src/SearchStrategies/SearchCache.ts create mode 100644 src/utils/PerformanceMonitor.ts create mode 100644 tests/SearchStrategies/SearchCache.test.ts create mode 100644 tests/SearchStrategies/SearchStrategy.caching.test.ts create mode 100644 tests/performance/benchmark.test.ts diff --git a/src/SearchStrategies/SearchCache.ts b/src/SearchStrategies/SearchCache.ts new file mode 100644 index 0000000..e05c3b6 --- /dev/null +++ b/src/SearchStrategies/SearchCache.ts @@ -0,0 +1,115 @@ +export interface CacheEntry { + value: T; + timestamp: number; + hits: number; +} + +export interface CacheOptions { + maxSize: number; + ttl: number; +} + +export interface CacheStats { + size: number; + maxSize: number; + ttl: number; + hits: number; + misses: number; + hitRate: number; +} + +export class SearchCache { + private cache = new Map>(); + private options: CacheOptions; + private hitCount = 0; + private missCount = 0; + + constructor(options: Partial = {}) { + this.options = { + maxSize: options.maxSize || 1000, + ttl: options.ttl || 60000 + }; + } + + get(key: string): T | undefined { + const entry = this.cache.get(key); + + if (!entry) { + this.missCount++; + return undefined; + } + + if (Date.now() - entry.timestamp > this.options.ttl) { + this.cache.delete(key); + this.missCount++; + return undefined; + } + + entry.hits++; + this.hitCount++; + + return entry.value; + } + + set(key: string, value: T): void { + if (this.cache.size >= this.options.maxSize) { + this.evictOldest(); + } + + this.cache.set(key, { + value, + timestamp: Date.now(), + hits: 0 + }); + } + + clear(): void { + this.cache.clear(); + this.hitCount = 0; + this.missCount = 0; + } + + private evictOldest(): void { + let oldestKey: string | undefined; + let lowestScore = Infinity; + + for (const [key, entry] of this.cache) { + const score = entry.timestamp + (entry.hits * 10000); + if (score < lowestScore) { + lowestScore = score; + oldestKey = key; + } + } + + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + getStats(): CacheStats { + const total = this.hitCount + this.missCount; + const hitRate = total > 0 ? this.hitCount / total : 0; + + return { + size: this.cache.size, + maxSize: this.options.maxSize, + ttl: this.options.ttl, + hits: this.hitCount, + misses: this.missCount, + hitRate: Math.round(hitRate * 10000) / 100 + }; + } + + has(key: string): boolean { + const entry = this.cache.get(key); + if (!entry) return false; + + if (Date.now() - entry.timestamp > this.options.ttl) { + this.cache.delete(key); + return false; + } + + return true; + } +} + diff --git a/src/SearchStrategies/search/findMatches.ts b/src/SearchStrategies/search/findMatches.ts index bac843f..9e6c475 100644 --- a/src/SearchStrategies/search/findMatches.ts +++ b/src/SearchStrategies/search/findMatches.ts @@ -1,19 +1,30 @@ import { MatchInfo } from '../types'; export function findLiteralMatches(text: string, criteria: string): MatchInfo[] { + const lowerText = text.trim().toLowerCase(); + const pattern = criteria.endsWith(' ') + ? [criteria.toLowerCase()] + : criteria.trim().toLowerCase().split(' '); + + const wordsFound = pattern.filter((word: string) => lowerText.indexOf(word) >= 0).length; + + if (wordsFound !== pattern.length) { + return []; + } + const matches: MatchInfo[] = []; - const lowerText = text.toLowerCase(); - const lowerCriteria = criteria.toLowerCase(); - let startIndex = 0; - while ((startIndex = lowerText.indexOf(lowerCriteria, startIndex)) !== -1) { - matches.push({ - start: startIndex, - end: startIndex + criteria.length, - text: text.substring(startIndex, startIndex + criteria.length), - type: 'exact' - }); - startIndex += criteria.length; + for (const word of pattern) { + let startIndex = 0; + while ((startIndex = lowerText.indexOf(word, startIndex)) !== -1) { + matches.push({ + start: startIndex, + end: startIndex + word.length, + text: text.substring(startIndex, startIndex + word.length), + type: 'exact' + }); + startIndex += word.length; + } } return matches; diff --git a/src/SearchStrategies/types.ts b/src/SearchStrategies/types.ts index a852725..27e530a 100644 --- a/src/SearchStrategies/types.ts +++ b/src/SearchStrategies/types.ts @@ -1,3 +1,5 @@ +import { SearchCache } from './SearchCache'; + export interface MatchInfo { start: number; end: number; @@ -8,18 +10,24 @@ export interface MatchInfo { export interface Matcher { matches(text: string | null, criteria: string): boolean; findMatches?(text: string | null, criteria: string): MatchInfo[]; + clearCache?(): void; + getCacheStats?(): { hitRate: number; size: number }; +} + +interface CachedResult { + matches: boolean; + matchInfo: MatchInfo[]; } export class SearchStrategy implements Matcher { - private readonly matchFunction: (text: string, criteria: string) => boolean; - private readonly findMatchesFunction?: (text: string, criteria: string) => MatchInfo[]; + private readonly findMatchesFunction: (text: string, criteria: string) => MatchInfo[]; + private readonly cache: SearchCache; constructor( - matchFunction: (text: string, criteria: string) => boolean, - findMatchesFunction?: (text: string, criteria: string) => MatchInfo[] + findMatchesFunction: (text: string, criteria: string) => MatchInfo[] ) { - this.matchFunction = matchFunction; this.findMatchesFunction = findMatchesFunction; + this.cache = new SearchCache({ maxSize: 500, ttl: 60000 }); } matches(text: string | null, criteria: string): boolean { @@ -27,7 +35,20 @@ export class SearchStrategy implements Matcher { return false; } - return this.matchFunction(text, criteria); + const cacheKey = this.getCacheKey(text, criteria); + const cached = this.cache.get(cacheKey); + if (cached !== undefined) { + return cached.matches; + } + + const matchInfo = this.findMatchesInternal(text, criteria); + const result: CachedResult = { + matches: matchInfo.length > 0, + matchInfo + }; + + this.cache.set(cacheKey, result); + return result.matches; } findMatches(text: string | null, criteria: string): MatchInfo[] { @@ -35,10 +56,42 @@ export class SearchStrategy implements Matcher { return []; } + const cacheKey = this.getCacheKey(text, criteria); + const cached = this.cache.get(cacheKey); + if (cached !== undefined) { + return cached.matchInfo; + } + + const matchInfo = this.findMatchesInternal(text, criteria); + const result: CachedResult = { + matches: matchInfo.length > 0, + matchInfo + }; + + this.cache.set(cacheKey, result); + return result.matchInfo; + } + + private findMatchesInternal(text: string, criteria: string): MatchInfo[] { if (this.findMatchesFunction) { return this.findMatchesFunction(text, criteria); } - return []; } + + private getCacheKey(text: string, criteria: string): string { + return `${text.length}:${criteria}:${text.substring(0, 20)}`; + } + + clearCache(): void { + this.cache.clear(); + } + + getCacheStats(): { hitRate: number; size: number } { + const stats = this.cache.getStats(); + return { + hitRate: stats.hitRate, + size: stats.size + }; + } } \ No newline at end of file diff --git a/src/utils/PerformanceMonitor.ts b/src/utils/PerformanceMonitor.ts new file mode 100644 index 0000000..40a05cf --- /dev/null +++ b/src/utils/PerformanceMonitor.ts @@ -0,0 +1,49 @@ +export interface PerformanceMetrics { + searchCount: number; + totalTime: number; + averageTime: number; + cacheHits: number; + cacheMisses: number; + cacheHitRate: number; +} + +export class PerformanceMonitor { + private searchCount = 0; + private totalTime = 0; + private cacheHits = 0; + private cacheMisses = 0; + + recordSearch(duration: number, cacheHit: boolean): void { + this.searchCount++; + this.totalTime += duration; + + if (cacheHit) { + this.cacheHits++; + } else { + this.cacheMisses++; + } + } + + getMetrics(): PerformanceMetrics { + const total = this.cacheHits + this.cacheMisses; + const cacheHitRate = total > 0 ? this.cacheHits / total : 0; + const averageTime = this.searchCount > 0 ? this.totalTime / this.searchCount : 0; + + return { + searchCount: this.searchCount, + totalTime: Math.round(this.totalTime * 100) / 100, + averageTime: Math.round(averageTime * 100) / 100, + cacheHits: this.cacheHits, + cacheMisses: this.cacheMisses, + cacheHitRate: Math.round(cacheHitRate * 10000) / 100 + }; + } + + reset(): void { + this.searchCount = 0; + this.totalTime = 0; + this.cacheHits = 0; + this.cacheMisses = 0; + } +} + diff --git a/tests/SearchStrategies/SearchCache.test.ts b/tests/SearchStrategies/SearchCache.test.ts new file mode 100644 index 0000000..a196988 --- /dev/null +++ b/tests/SearchStrategies/SearchCache.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import { SearchCache } from '../../src/SearchStrategies/SearchCache'; + +describe('SearchCache', () => { + let cache: SearchCache; + + beforeEach(() => { + cache = new SearchCache({ maxSize: 3, ttl: 1000 }); + }); + + describe('basic operations', () => { + it('should store and retrieve values', () => { + cache.set('key1', 'value1'); + expect(cache.get('key1')).toBe('value1'); + }); + + it('should return undefined for non-existent keys', () => { + expect(cache.get('nonexistent')).toBeUndefined(); + }); + + it('should update hits counter on cache hit', () => { + cache.set('key1', 'value1'); + cache.get('key1'); + cache.get('key1'); + + const stats = cache.getStats(); + expect(stats.hits).toBe(2); + expect(stats.misses).toBe(0); + }); + + it('should update misses counter on cache miss', () => { + cache.get('nonexistent'); + cache.get('another-miss'); + + const stats = cache.getStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(2); + }); + + it('should check if key exists', () => { + cache.set('key1', 'value1'); + expect(cache.has('key1')).toBe(true); + expect(cache.has('nonexistent')).toBe(false); + }); + }); + + describe('TTL (Time To Live)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should expire entries after TTL', () => { + cache.set('key1', 'value1'); + expect(cache.get('key1')).toBe('value1'); + + vi.advanceTimersByTime(1001); + + expect(cache.get('key1')).toBeUndefined(); + }); + + it('should not expire entries before TTL', () => { + cache.set('key1', 'value1'); + + vi.advanceTimersByTime(500); + + expect(cache.get('key1')).toBe('value1'); + }); + + it('should count expired entry as miss', () => { + cache.set('key1', 'value1'); + cache.get('key1'); + + vi.advanceTimersByTime(1001); + cache.get('key1'); + + const stats = cache.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + }); + + it('should remove expired entry on has() check', () => { + cache.set('key1', 'value1'); + expect(cache.has('key1')).toBe(true); + + vi.advanceTimersByTime(1001); + + expect(cache.has('key1')).toBe(false); + const stats = cache.getStats(); + expect(stats.size).toBe(0); + }); + }); + + describe('LRU eviction', () => { + it('should evict oldest entry when maxSize is reached', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + cache.set('key3', 'value3'); + cache.set('key4', 'value4'); + + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toBe('value2'); + expect(cache.get('key3')).toBe('value3'); + expect(cache.get('key4')).toBe('value4'); + }); + + it('should keep frequently accessed entries', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + cache.set('key3', 'value3'); + + cache.get('key1'); + cache.get('key1'); + cache.get('key1'); + + cache.set('key4', 'value4'); + + expect(cache.get('key1')).toBe('value1'); + expect(cache.has('key1')).toBe(true); + + const stats = cache.getStats(); + expect(stats.size).toBe(3); + }); + + it('should maintain maxSize constraint', () => { + for (let i = 0; i < 10; i++) { + cache.set(`key${i}`, `value${i}`); + } + + const stats = cache.getStats(); + expect(stats.size).toBe(3); + expect(stats.size).toBeLessThanOrEqual(stats.maxSize); + }); + }); + + describe('cache statistics', () => { + it('should track cache size', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + + const stats = cache.getStats(); + expect(stats.size).toBe(2); + }); + + it('should calculate hit rate correctly', () => { + cache.set('key1', 'value1'); + cache.get('key1'); + cache.get('key1'); + cache.get('nonexistent'); + + const stats = cache.getStats(); + expect(stats.hitRate).toBe(66.67); + }); + + it('should return 0 hit rate when no operations', () => { + const stats = cache.getStats(); + expect(stats.hitRate).toBe(0); + }); + + it('should include cache options in stats', () => { + const stats = cache.getStats(); + expect(stats.maxSize).toBe(3); + expect(stats.ttl).toBe(1000); + }); + }); + + describe('clear()', () => { + it('should remove all entries', () => { + cache.set('key1', 'value1'); + cache.set('key2', 'value2'); + cache.set('key3', 'value3'); + + cache.clear(); + + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toBeUndefined(); + expect(cache.get('key3')).toBeUndefined(); + + const stats = cache.getStats(); + expect(stats.size).toBe(0); + }); + + it('should reset hit and miss counters', () => { + cache.set('key1', 'value1'); + cache.get('key1'); + cache.get('nonexistent'); + + cache.clear(); + + const stats = cache.getStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + }); + }); + + describe('default options', () => { + it('should use default maxSize if not provided', () => { + const defaultCache = new SearchCache(); + const stats = defaultCache.getStats(); + expect(stats.maxSize).toBe(1000); + }); + + it('should use default TTL if not provided', () => { + const defaultCache = new SearchCache(); + const stats = defaultCache.getStats(); + expect(stats.ttl).toBe(60000); + }); + + it('should allow partial options', () => { + const customCache = new SearchCache({ maxSize: 50 }); + const stats = customCache.getStats(); + expect(stats.maxSize).toBe(50); + expect(stats.ttl).toBe(60000); + }); + }); + + describe('complex data types', () => { + it('should cache objects', () => { + const objCache = new SearchCache<{ name: string; age: number }>(); + const obj = { name: 'John', age: 30 }; + + objCache.set('user', obj); + const retrieved = objCache.get('user'); + + expect(retrieved).toEqual(obj); + }); + + it('should cache arrays', () => { + const arrCache = new SearchCache(); + const arr = [1, 2, 3, 4, 5]; + + arrCache.set('numbers', arr); + const retrieved = arrCache.get('numbers'); + + expect(retrieved).toEqual(arr); + }); + }); +}); + diff --git a/tests/SearchStrategies/SearchStrategy.caching.test.ts b/tests/SearchStrategies/SearchStrategy.caching.test.ts new file mode 100644 index 0000000..18d9007 --- /dev/null +++ b/tests/SearchStrategies/SearchStrategy.caching.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { SearchStrategy } from '../../src/SearchStrategies/types'; + +describe('SearchStrategy Caching', () => { + let strategy: SearchStrategy; + let matchFunctionCalls: number; + let findMatchesCalls: number; + + beforeEach(() => { + matchFunctionCalls = 0; + findMatchesCalls = 0; + + const matchFunction = (text: string, criteria: string) => { + matchFunctionCalls++; + return text.toLowerCase().includes(criteria.toLowerCase()); + }; + + const findMatchesFunction = (text: string, criteria: string) => { + findMatchesCalls++; + const lowerText = text.toLowerCase(); + const lowerCriteria = criteria.toLowerCase(); + const index = lowerText.indexOf(lowerCriteria); + + if (index !== -1) { + return [{ + start: index, + end: index + criteria.length, + text: text.substring(index, index + criteria.length), + type: 'exact' as const + }]; + } + + return []; + }; + + strategy = new SearchStrategy(matchFunction, findMatchesFunction); + }); + + describe('matches() caching', () => { + it('should cache matches() results', () => { + strategy.matches('hello world', 'hello'); + strategy.matches('hello world', 'hello'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(1); + }); + + it('should use different cache keys for different texts', () => { + strategy.matches('hello world', 'hello'); + strategy.matches('goodbye world', 'hello'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(2); + }); + + it('should use different cache keys for different criteria', () => { + strategy.matches('hello world', 'hello'); + strategy.matches('hello world', 'world'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(2); + }); + }); + + describe('findMatches() caching', () => { + it('should cache findMatches() results', () => { + strategy.findMatches('hello world', 'hello'); + strategy.findMatches('hello world', 'hello'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(1); + }); + + it('should return correct match info on cache hit', () => { + const result1 = strategy.findMatches('hello world', 'hello'); + const result2 = strategy.findMatches('hello world', 'hello'); + + expect(result2).toEqual(result1); + expect(result2[0].start).toBe(0); + expect(result2[0].end).toBe(5); + }); + }); + + describe('shared cache between matches() and findMatches()', () => { + it('should share cache between matches() and findMatches()', () => { + strategy.matches('hello world', 'hello'); + strategy.findMatches('hello world', 'hello'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(1); + }); + + it('should share cache in reverse order', () => { + strategy.findMatches('hello world', 'hello'); + strategy.matches('hello world', 'hello'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(1); + }); + }); + + describe('clearCache()', () => { + it('should clear the cache', () => { + strategy.matches('hello world', 'hello'); + strategy.clearCache(); + strategy.matches('hello world', 'hello'); + + expect(matchFunctionCalls).toBe(0); + expect(findMatchesCalls).toBe(2); + }); + + it('should reset cache statistics', () => { + strategy.matches('hello world', 'hello'); + strategy.matches('hello world', 'hello'); + + const statsBefore = strategy.getCacheStats(); + expect(statsBefore.hitRate).toBeGreaterThan(0); + + strategy.clearCache(); + + const statsAfter = strategy.getCacheStats(); + expect(statsAfter.hitRate).toBe(0); + expect(statsAfter.size).toBe(0); + }); + }); + + describe('getCacheStats()', () => { + it('should report cache statistics', () => { + strategy.matches('hello world', 'hello'); + strategy.matches('hello world', 'hello'); + strategy.matches('goodbye world', 'hello'); + + const stats = strategy.getCacheStats(); + + expect(stats.hitRate).toBe(33.33); + expect(stats.size).toBe(2); + }); + + it('should track cache hits correctly', () => { + strategy.matches('test', 'test'); + strategy.matches('test', 'test'); + strategy.matches('test', 'test'); + + const stats = strategy.getCacheStats(); + expect(stats.hitRate).toBe(66.67); + }); + }); + + describe('cache key generation', () => { + it('should handle long texts in cache keys', () => { + const longText = 'a'.repeat(1000); + + strategy.matches(longText, 'a'); + strategy.matches(longText, 'a'); + + expect(findMatchesCalls).toBe(1); + }); + + it('should handle special characters in cache keys', () => { + strategy.matches('hello:world', 'hello'); + strategy.matches('hello:world', 'hello'); + + expect(findMatchesCalls).toBe(1); + }); + }); + + describe('edge cases', () => { + it('should not cache null text', () => { + strategy.matches(null, 'test'); + strategy.matches(null, 'test'); + + expect(findMatchesCalls).toBe(0); + }); + + it('should not cache empty text', () => { + strategy.matches('', 'test'); + strategy.matches('', 'test'); + + expect(findMatchesCalls).toBe(0); + }); + + it('should not cache empty criteria', () => { + strategy.matches('test', ''); + strategy.matches('test', ''); + + expect(findMatchesCalls).toBe(0); + }); + }); + + describe('cache performance', () => { + it('should significantly speed up repeated searches', () => { + const text = 'Lorem ipsum dolor sit amet'; + const criteria = 'ipsum'; + + const start1 = performance.now(); + strategy.matches(text, criteria); + const time1 = performance.now() - start1; + + const start2 = performance.now(); + strategy.matches(text, criteria); + const time2 = performance.now() - start2; + + expect(time2).toBeLessThan(time1); + }); + }); +}); + diff --git a/tests/performance/benchmark.test.ts b/tests/performance/benchmark.test.ts new file mode 100644 index 0000000..585f66d --- /dev/null +++ b/tests/performance/benchmark.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SearchStrategy } from '../../src/SearchStrategies/types'; +import { PerformanceMonitor } from '../../src/utils/PerformanceMonitor'; + +describe('Performance Benchmarks', () => { + const sampleTexts = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', + 'The quick brown fox jumps over the lazy dog', + 'JavaScript is a programming language', + 'Search functionality with caching improves performance', + 'TypeScript adds static typing to JavaScript', + 'Unit tests ensure code quality and correctness', + 'Performance optimization is crucial for user experience', + 'Cache hit rate measures cache effectiveness' + ]; + + const queries = ['lorem', 'quick', 'javascript', 'search', 'performance']; + + let strategy: SearchStrategy; + let monitor: PerformanceMonitor; + + beforeEach(() => { + const matchFunction = (text: string, criteria: string) => { + return text.toLowerCase().includes(criteria.toLowerCase()); + }; + + const findMatchesFunction = (text: string, criteria: string) => { + const lowerText = text.toLowerCase(); + const lowerCriteria = criteria.toLowerCase(); + const matches = []; + let startIndex = 0; + + while (true) { + const index = lowerText.indexOf(lowerCriteria, startIndex); + if (index === -1) break; + + matches.push({ + start: index, + end: index + criteria.length, + text: text.substring(index, index + criteria.length), + type: 'exact' as const + }); + + startIndex = index + 1; + } + + return matches; + }; + + strategy = new SearchStrategy(matchFunction, findMatchesFunction); + monitor = new PerformanceMonitor(); + }); + + describe('Cache Hit Rate', () => { + it('should achieve high cache hit rate on repeated searches', () => { + for (let i = 0; i < 100; i++) { + const text = sampleTexts[i % sampleTexts.length]; + const query = queries[i % queries.length]; + strategy.matches(text, query); + } + + const stats = strategy.getCacheStats(); + expect(stats.hitRate).toBeGreaterThan(50); + expect(stats.hitRate).toBeLessThanOrEqual(100); + }); + + it('should measure cache effectiveness with PerformanceMonitor', () => { + for (let i = 0; i < 50; i++) { + const text = sampleTexts[i % sampleTexts.length]; + const query = queries[i % queries.length]; + + const start = performance.now(); + strategy.matches(text, query); + const duration = performance.now() - start; + + const cacheHit = i >= sampleTexts.length; + monitor.recordSearch(duration, cacheHit); + } + + const metrics = monitor.getMetrics(); + expect(metrics.cacheHitRate).toBeGreaterThan(50); + expect(metrics.searchCount).toBe(50); + }); + }); + + describe('Performance Improvement', () => { + it('should show faster average time with caching', () => { + const withoutCache: number[] = []; + const withCache: number[] = []; + + for (let i = 0; i < 10; i++) { + strategy.clearCache(); + + const start1 = performance.now(); + strategy.matches(sampleTexts[0], queries[0]); + withoutCache.push(performance.now() - start1); + + const start2 = performance.now(); + strategy.matches(sampleTexts[0], queries[0]); + withCache.push(performance.now() - start2); + } + + const avgWithoutCache = withoutCache.reduce((a, b) => a + b, 0) / withoutCache.length; + const avgWithCache = withCache.reduce((a, b) => a + b, 0) / withCache.length; + + expect(avgWithCache).toBeLessThan(avgWithoutCache); + }); + + it('should demonstrate cache speedup factor', () => { + let uncachedTime = 0; + let cachedTime = 0; + + strategy.clearCache(); + const start1 = performance.now(); + for (let i = 0; i < sampleTexts.length; i++) { + strategy.matches(sampleTexts[i], queries[i % queries.length]); + } + uncachedTime = performance.now() - start1; + + const start2 = performance.now(); + for (let i = 0; i < sampleTexts.length; i++) { + strategy.matches(sampleTexts[i], queries[i % queries.length]); + } + cachedTime = performance.now() - start2; + + const speedupFactor = uncachedTime / cachedTime; + expect(speedupFactor).toBeGreaterThan(1); + }); + }); + + describe('findMatches() Performance', () => { + it('should cache findMatches() results effectively', () => { + for (let i = 0; i < 50; i++) { + const text = sampleTexts[i % sampleTexts.length]; + const query = queries[i % queries.length]; + strategy.findMatches(text, query); + } + + const stats = strategy.getCacheStats(); + expect(stats.hitRate).toBeGreaterThan(10); + expect(stats.size).toBeGreaterThan(0); + }); + }); + + describe('Shared Cache Performance', () => { + it('should benefit from shared cache between matches() and findMatches()', () => { + for (let i = 0; i < 25; i++) { + const text = sampleTexts[i % sampleTexts.length]; + const query = queries[i % queries.length]; + strategy.matches(text, query); + } + + for (let i = 0; i < 25; i++) { + const text = sampleTexts[i % sampleTexts.length]; + const query = queries[i % queries.length]; + strategy.findMatches(text, query); + } + + const stats = strategy.getCacheStats(); + expect(stats.hitRate).toBeGreaterThanOrEqual(40); + }); + }); + + describe('PerformanceMonitor Metrics', () => { + it('should accurately track search metrics', () => { + monitor.reset(); + + for (let i = 0; i < 20; i++) { + const duration = Math.random() * 10; + const cacheHit = i % 2 === 0; + monitor.recordSearch(duration, cacheHit); + } + + const metrics = monitor.getMetrics(); + + expect(metrics.searchCount).toBe(20); + expect(metrics.cacheHits).toBe(10); + expect(metrics.cacheMisses).toBe(10); + expect(metrics.cacheHitRate).toBe(50); + expect(metrics.totalTime).toBeGreaterThan(0); + expect(metrics.averageTime).toBeGreaterThan(0); + }); + + it('should calculate correct averages', () => { + monitor.reset(); + + monitor.recordSearch(10, false); + monitor.recordSearch(20, true); + monitor.recordSearch(30, false); + + const metrics = monitor.getMetrics(); + + expect(metrics.totalTime).toBe(60); + expect(metrics.averageTime).toBe(20); + }); + }); + + describe('Real-world Search Patterns', () => { + it('should handle typical search patterns efficiently', () => { + const searchPatterns = [ + { text: 'hello world', query: 'hello' }, + { text: 'hello world', query: 'world' }, + { text: 'hello world', query: 'hello' }, + { text: 'goodbye world', query: 'world' }, + { text: 'hello world', query: 'hello' }, + { text: 'hello world', query: 'world' }, + { text: 'test data', query: 'test' }, + { text: 'hello world', query: 'hello' } + ]; + + for (const pattern of searchPatterns) { + strategy.matches(pattern.text, pattern.query); + } + + const stats = strategy.getCacheStats(); + expect(stats.hitRate).toBeGreaterThanOrEqual(40); + expect(stats.size).toBeGreaterThan(0); + }); + }); +}); + From 7e1b2088adc26aff1b124a404a75b067b287ea9c Mon Sep 17 00:00:00 2001 From: sylhare Date: Thu, 16 Oct 2025 11:25:11 -0400 Subject: [PATCH 05/18] Use findMatch instead of search algorithm --- src/SearchStrategies/SearchStrategy.ts | 14 +-- .../search/findFuzzyMatches.ts | 48 ++++++++ ...venshtein.ts => findLevenshteinMatches.ts} | 38 +++--- .../search/findLiteralMatches.ts | 41 +++++++ src/SearchStrategies/search/findMatches.ts | 92 -------------- .../search/findWildcardMatches.ts | 32 +++++ src/SearchStrategies/search/fuzzySearch.ts | 38 ------ src/SearchStrategies/search/literalSearch.ts | 13 -- src/SearchStrategies/search/wildcardSearch.ts | 12 -- src/SearchStrategies/types.ts | 5 +- .../SearchStrategy.caching.test.ts | 16 +-- .../SearchStrategies/findFuzzyMatches.test.ts | 99 +++++++++++++++ .../findLevenshteinMatches.test.ts | 82 +++++++++++++ .../findLiteralMatches.test.ts | 62 ++++++++++ tests/SearchStrategies/findMatches.test.ts | 113 ------------------ .../findWildcardMatches.test.ts | 59 +++++++++ tests/SearchStrategies/fuzzySearch.test.ts | 93 -------------- tests/SearchStrategies/levenshtein.test.ts | 83 ------------- tests/SearchStrategies/literalSearch.test.ts | 9 -- tests/SearchStrategies/wildcardSearch.test.ts | 43 ------- tests/performance/benchmark.test.ts | 2 +- {src => tests}/utils/PerformanceMonitor.ts | 0 22 files changed, 451 insertions(+), 543 deletions(-) create mode 100644 src/SearchStrategies/search/findFuzzyMatches.ts rename src/SearchStrategies/search/{levenshtein.ts => findLevenshteinMatches.ts} (55%) create mode 100644 src/SearchStrategies/search/findLiteralMatches.ts delete mode 100644 src/SearchStrategies/search/findMatches.ts create mode 100644 src/SearchStrategies/search/findWildcardMatches.ts delete mode 100644 src/SearchStrategies/search/fuzzySearch.ts delete mode 100644 src/SearchStrategies/search/literalSearch.ts delete mode 100644 src/SearchStrategies/search/wildcardSearch.ts create mode 100644 tests/SearchStrategies/findFuzzyMatches.test.ts create mode 100644 tests/SearchStrategies/findLevenshteinMatches.test.ts create mode 100644 tests/SearchStrategies/findLiteralMatches.test.ts delete mode 100644 tests/SearchStrategies/findMatches.test.ts create mode 100644 tests/SearchStrategies/findWildcardMatches.test.ts delete mode 100644 tests/SearchStrategies/fuzzySearch.test.ts delete mode 100644 tests/SearchStrategies/levenshtein.test.ts delete mode 100644 tests/SearchStrategies/literalSearch.test.ts delete mode 100644 tests/SearchStrategies/wildcardSearch.test.ts rename {src => tests}/utils/PerformanceMonitor.ts (100%) diff --git a/src/SearchStrategies/SearchStrategy.ts b/src/SearchStrategies/SearchStrategy.ts index 4bbbd84..26eafb3 100644 --- a/src/SearchStrategies/SearchStrategy.ts +++ b/src/SearchStrategies/SearchStrategy.ts @@ -1,18 +1,13 @@ -import { fuzzySearch } from './search/fuzzySearch'; -import { literalSearch } from './search/literalSearch'; -import { wildcardSearch } from './search/wildcardSearch'; -import { findLiteralMatches, findFuzzyMatches, findWildcardMatches } from './search/findMatches'; +import { findLiteralMatches } from './search/findLiteralMatches'; +import { findFuzzyMatches } from './search/findFuzzyMatches'; +import { findWildcardMatches } from './search/findWildcardMatches'; import { SearchStrategy } from './types'; export const LiteralSearchStrategy = new SearchStrategy( - literalSearch, findLiteralMatches ); export const FuzzySearchStrategy = new SearchStrategy( - (text: string, criteria: string) => { - return fuzzySearch(text, criteria) || literalSearch(text, criteria); - }, (text: string, criteria: string) => { const fuzzyMatches = findFuzzyMatches(text, criteria); if (fuzzyMatches.length > 0) { @@ -23,9 +18,6 @@ export const FuzzySearchStrategy = new SearchStrategy( ); export const WildcardSearchStrategy = new SearchStrategy( - (text: string, criteria: string) => { - return wildcardSearch(text, criteria) || literalSearch(text, criteria); - }, (text: string, criteria: string) => { const wildcardMatches = findWildcardMatches(text, criteria); if (wildcardMatches.length > 0) { diff --git a/src/SearchStrategies/search/findFuzzyMatches.ts b/src/SearchStrategies/search/findFuzzyMatches.ts new file mode 100644 index 0000000..de0cd93 --- /dev/null +++ b/src/SearchStrategies/search/findFuzzyMatches.ts @@ -0,0 +1,48 @@ +import { MatchInfo } from '../types'; + +/** + * Finds fuzzy matches where characters appear in sequence (but not necessarily consecutively). + * Returns a single match spanning from the first to last matched character. + * + * @param text - The text to search in + * @param criteria - The search criteria + * @returns Array with single MatchInfo if all characters found in sequence, empty array otherwise + */ +export function findFuzzyMatches(text: string, criteria: string): MatchInfo[] { + criteria = criteria.trimEnd(); + if (criteria.length === 0) return []; + + const lowerText = text.toLowerCase(); + const lowerCriteria = criteria.toLowerCase(); + + let textIndex = 0; + let criteriaIndex = 0; + const matchedIndices: number[] = []; + + while (textIndex < text.length && criteriaIndex < criteria.length) { + if (lowerText[textIndex] === lowerCriteria[criteriaIndex]) { + matchedIndices.push(textIndex); + criteriaIndex++; + } + textIndex++; + } + + if (criteriaIndex !== criteria.length) { + return []; + } + + if (matchedIndices.length === 0) { + return []; + } + + const start = matchedIndices[0]; + const end = matchedIndices[matchedIndices.length - 1] + 1; + + return [{ + start, + end, + text: text.substring(start, end), + type: 'fuzzy' + }]; +} + diff --git a/src/SearchStrategies/search/levenshtein.ts b/src/SearchStrategies/search/findLevenshteinMatches.ts similarity index 55% rename from src/SearchStrategies/search/levenshtein.ts rename to src/SearchStrategies/search/findLevenshteinMatches.ts index f4da263..92adbb7 100644 --- a/src/SearchStrategies/search/levenshtein.ts +++ b/src/SearchStrategies/search/findLevenshteinMatches.ts @@ -1,3 +1,5 @@ +import { MatchInfo } from '../types'; + /** * Calculates the Levenshtein distance between two strings. * @@ -5,49 +7,53 @@ * It is calculated as the minimum number of single-character edits (insertions, deletions, or substitutions) * required to change one string into the other. * - * Example: - * For the strings 'a' and 'b', the Levenshtein distance is 1 because: - * - Substituting 'a' with 'b' results in the string 'b'. - * * @param a - The first string * @param b - The second string * @returns The Levenshtein distance */ -export function levenshtein(a: string, b: string): number { +function levenshtein(a: string, b: string): number { const lenA = a.length; const lenB = b.length; const distanceMatrix: number[][] = Array.from({ length: lenA + 1 }, () => Array(lenB + 1).fill(0)); - // Initialize the first row and column 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; - // Calculate the minimum cost of the three possible operations to make it closer to the other string 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 + distanceMatrix[i - 1][j] + 1, + distanceMatrix[i][j - 1] + 1, + distanceMatrix[i - 1][j - 1] + cost ); } } - // Return the distance between the two strings return distanceMatrix[lenA][lenB]; } /** - * Matches a pattern against a text with a set degree of certainty - * using the levenshtein distance. + * Finds matches based on Levenshtein distance (edit distance). + * Returns a match if the similarity is >= 30% (edit distance allows for typos). * * @param text - The text to search in * @param pattern - The pattern to search for + * @returns Array with single MatchInfo if similarity threshold met, empty array otherwise */ -export function levenshteinSearch(text: string, pattern: string): boolean { +export function findLevenshteinMatches(text: string, pattern: string): MatchInfo[] { const distance = levenshtein(pattern, text); const similarity = 1 - distance / Math.max(pattern.length, text.length); - return similarity >= 0.3; -} \ No newline at end of file + if (similarity >= 0.3) { + return [{ + start: 0, + end: text.length, + text: text, + type: 'fuzzy' + }]; + } + + return []; +} + diff --git a/src/SearchStrategies/search/findLiteralMatches.ts b/src/SearchStrategies/search/findLiteralMatches.ts new file mode 100644 index 0000000..76b2077 --- /dev/null +++ b/src/SearchStrategies/search/findLiteralMatches.ts @@ -0,0 +1,41 @@ +import { MatchInfo } from '../types'; + +/** + * Finds all literal matches of a search criteria in the text. + * Handles multi-word searches by splitting on spaces and finding each word. + * All words must be present for a match. + * + * @param text - The text to search in + * @param criteria - The search criteria (can be multi-word) + * @returns Array of MatchInfo objects for each word found + */ +export function findLiteralMatches(text: string, criteria: string): MatchInfo[] { + const lowerText = text.trim().toLowerCase(); + const pattern = criteria.endsWith(' ') + ? [criteria.toLowerCase()] + : criteria.trim().toLowerCase().split(' '); + + const wordsFound = pattern.filter((word: string) => lowerText.indexOf(word) >= 0).length; + + if (wordsFound !== pattern.length) { + return []; + } + + const matches: MatchInfo[] = []; + + for (const word of pattern) { + let startIndex = 0; + while ((startIndex = lowerText.indexOf(word, startIndex)) !== -1) { + matches.push({ + start: startIndex, + end: startIndex + word.length, + text: text.substring(startIndex, startIndex + word.length), + type: 'exact' + }); + startIndex += word.length; + } + } + + return matches; +} + diff --git a/src/SearchStrategies/search/findMatches.ts b/src/SearchStrategies/search/findMatches.ts deleted file mode 100644 index 9e6c475..0000000 --- a/src/SearchStrategies/search/findMatches.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { MatchInfo } from '../types'; - -export function findLiteralMatches(text: string, criteria: string): MatchInfo[] { - const lowerText = text.trim().toLowerCase(); - const pattern = criteria.endsWith(' ') - ? [criteria.toLowerCase()] - : criteria.trim().toLowerCase().split(' '); - - const wordsFound = pattern.filter((word: string) => lowerText.indexOf(word) >= 0).length; - - if (wordsFound !== pattern.length) { - return []; - } - - const matches: MatchInfo[] = []; - - for (const word of pattern) { - let startIndex = 0; - while ((startIndex = lowerText.indexOf(word, startIndex)) !== -1) { - matches.push({ - start: startIndex, - end: startIndex + word.length, - text: text.substring(startIndex, startIndex + word.length), - type: 'exact' - }); - startIndex += word.length; - } - } - - return matches; -} - -export function findFuzzyMatches(text: string, criteria: string): MatchInfo[] { - criteria = criteria.trimEnd(); - if (criteria.length === 0) return []; - - const lowerText = text.toLowerCase(); - const lowerCriteria = criteria.toLowerCase(); - - let textIndex = 0; - let criteriaIndex = 0; - const matchedIndices: number[] = []; - - while (textIndex < text.length && criteriaIndex < criteria.length) { - if (lowerText[textIndex] === lowerCriteria[criteriaIndex]) { - matchedIndices.push(textIndex); - criteriaIndex++; - } - textIndex++; - } - - if (criteriaIndex !== criteria.length) { - return []; - } - - if (matchedIndices.length === 0) { - return []; - } - - const start = matchedIndices[0]; - const end = matchedIndices[matchedIndices.length - 1] + 1; - - return [{ - start, - end, - text: text.substring(start, end), - type: 'fuzzy' - }]; -} - -export function findWildcardMatches(text: string, pattern: string): MatchInfo[] { - const regexPattern = pattern.replace(/\*/g, '.*'); - const regex = new RegExp(regexPattern, 'gi'); - const matches: MatchInfo[] = []; - - let match; - while ((match = regex.exec(text)) !== null) { - matches.push({ - start: match.index, - end: match.index + match[0].length, - text: match[0], - type: 'wildcard' - }); - - if (regex.lastIndex === match.index) { - regex.lastIndex++; - } - } - - return matches; -} - diff --git a/src/SearchStrategies/search/findWildcardMatches.ts b/src/SearchStrategies/search/findWildcardMatches.ts new file mode 100644 index 0000000..be2ede3 --- /dev/null +++ b/src/SearchStrategies/search/findWildcardMatches.ts @@ -0,0 +1,32 @@ +import { MatchInfo } from '../types'; + +/** + * Finds matches using wildcard patterns (* matches any characters). + * Uses regex to find all matching patterns in the text. + * + * @param text - The text to search in + * @param pattern - The wildcard pattern (e.g., "hel*rld" matches "hello world") + * @returns Array of MatchInfo objects for each wildcard match + */ +export function findWildcardMatches(text: string, pattern: string): MatchInfo[] { + const regexPattern = pattern.replace(/\*/g, '.*'); + const regex = new RegExp(regexPattern, 'gi'); + const matches: MatchInfo[] = []; + + let match; + while ((match = regex.exec(text)) !== null) { + matches.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + type: 'wildcard' + }); + + if (regex.lastIndex === match.index) { + regex.lastIndex++; + } + } + + return matches; +} + diff --git a/src/SearchStrategies/search/fuzzySearch.ts b/src/SearchStrategies/search/fuzzySearch.ts deleted file mode 100644 index f54467c..0000000 --- a/src/SearchStrategies/search/fuzzySearch.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * A simple fuzzy search implementation that checks if characters in the pattern appear - * in order (but not necessarily consecutively) in the text. - * - * - Case-insensitive. - * - Ignores trailing spaces in the pattern. - * - Empty text matches nothing (unless pattern is also empty) - * - Empty pattern matches everything - * - * @param pattern - The pattern to search for - * @param text - The text to search in - * @returns true if all characters in pattern appear in order in text - */ -export function fuzzySearch(text: string, pattern: string): boolean { - pattern = pattern.trimEnd(); - if (pattern.length === 0) return true; - - pattern = pattern.toLowerCase(); - text = text.toLowerCase(); - - let remainingText = text, currentIndex = -1; - - // For each character in the pattern - for (const char of pattern) { - // Find the next occurrence of this character in the remaining text - const nextIndex = remainingText.indexOf(char); - - // If the character is not found or is too far from the current character - if (nextIndex === -1 || (currentIndex !== -1 && remainingText.slice(0, nextIndex).split(' ').length - 1 > 2)) { - return false; - } - - currentIndex = nextIndex; - remainingText = remainingText.slice(nextIndex + 1); - } - - return true; -} \ No newline at end of file diff --git a/src/SearchStrategies/search/literalSearch.ts b/src/SearchStrategies/search/literalSearch.ts deleted file mode 100644 index a2dbb21..0000000 --- a/src/SearchStrategies/search/literalSearch.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * This function performs a literal search (look for the exact sequence of characters) - * on the given text using the provided criteria. - * - * @param text - * @param criteria - */ -export function literalSearch(text: string, criteria: string): boolean { - text = text.trim().toLowerCase(); - const pattern = criteria.endsWith(' ') ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(' '); - - return pattern.filter((word: string) => text.indexOf(word) >= 0).length === pattern.length; -} \ No newline at end of file diff --git a/src/SearchStrategies/search/wildcardSearch.ts b/src/SearchStrategies/search/wildcardSearch.ts deleted file mode 100644 index f302888..0000000 --- a/src/SearchStrategies/search/wildcardSearch.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Matches a pattern with wildcards (*) against a text. - * - * @param text - The text to search in - * @param pattern - The pattern to search for (supports * as a wildcard for unknown characters) - * @returns true if the pattern matches, false otherwise - */ -export function wildcardSearch(text: string, pattern: string): boolean { - const regexPattern = pattern.replace(/\*/g, '.*'); - const regex = new RegExp(`^${regexPattern}$`, 'i'); - return regex.test(text); -} \ No newline at end of file diff --git a/src/SearchStrategies/types.ts b/src/SearchStrategies/types.ts index 27e530a..3768c77 100644 --- a/src/SearchStrategies/types.ts +++ b/src/SearchStrategies/types.ts @@ -73,10 +73,7 @@ export class SearchStrategy implements Matcher { } private findMatchesInternal(text: string, criteria: string): MatchInfo[] { - if (this.findMatchesFunction) { - return this.findMatchesFunction(text, criteria); - } - return []; + return this.findMatchesFunction(text, criteria); } private getCacheKey(text: string, criteria: string): string { diff --git a/tests/SearchStrategies/SearchStrategy.caching.test.ts b/tests/SearchStrategies/SearchStrategy.caching.test.ts index 18d9007..435aab1 100644 --- a/tests/SearchStrategies/SearchStrategy.caching.test.ts +++ b/tests/SearchStrategies/SearchStrategy.caching.test.ts @@ -3,18 +3,11 @@ import { SearchStrategy } from '../../src/SearchStrategies/types'; describe('SearchStrategy Caching', () => { let strategy: SearchStrategy; - let matchFunctionCalls: number; let findMatchesCalls: number; beforeEach(() => { - matchFunctionCalls = 0; findMatchesCalls = 0; - const matchFunction = (text: string, criteria: string) => { - matchFunctionCalls++; - return text.toLowerCase().includes(criteria.toLowerCase()); - }; - const findMatchesFunction = (text: string, criteria: string) => { findMatchesCalls++; const lowerText = text.toLowerCase(); @@ -33,7 +26,7 @@ describe('SearchStrategy Caching', () => { return []; }; - strategy = new SearchStrategy(matchFunction, findMatchesFunction); + strategy = new SearchStrategy(findMatchesFunction); }); describe('matches() caching', () => { @@ -41,7 +34,6 @@ describe('SearchStrategy Caching', () => { strategy.matches('hello world', 'hello'); strategy.matches('hello world', 'hello'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(1); }); @@ -49,7 +41,6 @@ describe('SearchStrategy Caching', () => { strategy.matches('hello world', 'hello'); strategy.matches('goodbye world', 'hello'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(2); }); @@ -57,7 +48,6 @@ describe('SearchStrategy Caching', () => { strategy.matches('hello world', 'hello'); strategy.matches('hello world', 'world'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(2); }); }); @@ -67,7 +57,6 @@ describe('SearchStrategy Caching', () => { strategy.findMatches('hello world', 'hello'); strategy.findMatches('hello world', 'hello'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(1); }); @@ -86,7 +75,6 @@ describe('SearchStrategy Caching', () => { strategy.matches('hello world', 'hello'); strategy.findMatches('hello world', 'hello'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(1); }); @@ -94,7 +82,6 @@ describe('SearchStrategy Caching', () => { strategy.findMatches('hello world', 'hello'); strategy.matches('hello world', 'hello'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(1); }); }); @@ -105,7 +92,6 @@ describe('SearchStrategy Caching', () => { strategy.clearCache(); strategy.matches('hello world', 'hello'); - expect(matchFunctionCalls).toBe(0); expect(findMatchesCalls).toBe(2); }); diff --git a/tests/SearchStrategies/findFuzzyMatches.test.ts b/tests/SearchStrategies/findFuzzyMatches.test.ts new file mode 100644 index 0000000..eaaae87 --- /dev/null +++ b/tests/SearchStrategies/findFuzzyMatches.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { findFuzzyMatches } from '../../src/SearchStrategies/search/findFuzzyMatches'; + +describe('findFuzzyMatches', () => { + it('matches exact strings', () => { + const matches1 = findFuzzyMatches('hello', 'hello'); + expect(matches1).toHaveLength(1); + expect(matches1[0].type).toBe('fuzzy'); + + const matches2 = findFuzzyMatches('test', 'test'); + expect(matches2).toHaveLength(1); + expect(matches2[0].type).toBe('fuzzy'); + }); + + it('matches substrings', () => { + expect(findFuzzyMatches('hello', 'hlo')).toHaveLength(1); + expect(findFuzzyMatches('test', 'tst')).toHaveLength(1); + expect(findFuzzyMatches('fuzzy', 'fzy')).toHaveLength(1); + expect(findFuzzyMatches('react', 'rct')).toHaveLength(1); + expect(findFuzzyMatches('what the heck', 'wth')).toHaveLength(1); + }); + + it('matches characters in sequence', () => { + expect(findFuzzyMatches('hello world', 'hw')).toHaveLength(1); + expect(findFuzzyMatches('a1b2c3', 'abc')).toHaveLength(1); + }); + + it('does not match out-of-sequence characters', () => { + expect(findFuzzyMatches('abc', 'cba')).toEqual([]); + expect(findFuzzyMatches('abcd', 'dc')).toEqual([]); + }); + + it('does not match words that don\'t contain the search criteria', () => { + expect(findFuzzyMatches('fuzzy', 'fzyyy')).toEqual([]); + expect(findFuzzyMatches('react', 'angular')).toEqual([]); + expect(findFuzzyMatches('what the heck', 'wth?')).toEqual([]); + }); + + it('is case insensitive', () => { + expect(findFuzzyMatches('HELLO', 'hello')).toHaveLength(1); + expect(findFuzzyMatches('world', 'WORLD')).toHaveLength(1); + expect(findFuzzyMatches('hEllO', 'HeLLo')).toHaveLength(1); + expect(findFuzzyMatches('Different Cases', 'dc')).toHaveLength(1); + expect(findFuzzyMatches('UPPERCASE', 'upprcs')).toHaveLength(1); + expect(findFuzzyMatches('lowercase', 'lc')).toHaveLength(1); + expect(findFuzzyMatches('DiFfErENt cASeS', 'dc')).toHaveLength(1); + }); + + it('handles special characters', () => { + expect(findFuzzyMatches('hello!@#$', 'h!@#$')).toHaveLength(1); + expect(findFuzzyMatches('abc123xyz', '123')).toHaveLength(1); + }); + + it('handles spaces correctly', () => { + expect(findFuzzyMatches('hello world', 'hw')).toHaveLength(1); + expect(findFuzzyMatches('hello world', 'h w')).toHaveLength(1); + expect(findFuzzyMatches('hello world', 'hw ')).toHaveLength(1); + }); + + it('matches characters in sequence', () => { + expect(findFuzzyMatches('hello world', 'hlo wld')).toHaveLength(1); + expect(findFuzzyMatches('hello world', 'hw')).toHaveLength(1); + expect(findFuzzyMatches('hello world', 'hlowrd')).toHaveLength(1); + expect(findFuzzyMatches('hello world', 'wrld')).toHaveLength(1); + expect(findFuzzyMatches('hello world', 'wh')).toEqual([]); + }); + + it('does not match when character frequency in the pattern exceeds the text', () => { + expect(findFuzzyMatches('goggles', 'gggggggg')).toEqual([]); + expect(findFuzzyMatches('aab', 'aaaa')).toEqual([]); + }); + + it('match ordered multiple words', () => { + expect(findFuzzyMatches('Ola que tal', 'ola tal')).toHaveLength(1); + }); + + describe('original fuzzysearch test cases', () => { + it('matches cartwheel test cases', () => { + expect(findFuzzyMatches('cartwheel', 'car')).toHaveLength(1); + expect(findFuzzyMatches('cartwheel', 'cwhl')).toHaveLength(1); + expect(findFuzzyMatches('cartwheel', 'cwheel')).toHaveLength(1); + expect(findFuzzyMatches('cartwheel', 'cartwheel')).toHaveLength(1); + expect(findFuzzyMatches('cartwheel', 'cwheeel')).toEqual([]); + expect(findFuzzyMatches('cartwheel', 'lw')).toEqual([]); + }); + + it('matches Chinese Unicode test cases', () => { + expect(findFuzzyMatches('php语言', '语言')).toHaveLength(1); + expect(findFuzzyMatches('php语言', 'hp语')).toHaveLength(1); + expect(findFuzzyMatches('Python开发者', 'Py开发')).toHaveLength(1); + expect(findFuzzyMatches('Python开发者', 'Py 开发')).toEqual([]); + expect(findFuzzyMatches('爪哇开发进阶', '爪哇进阶')).toHaveLength(1); + expect(findFuzzyMatches('非常简单的格式化工具', '格式工具')).toHaveLength(1); + expect(findFuzzyMatches('学习正则表达式怎么学习', '正则')).toHaveLength(1); + expect(findFuzzyMatches('正则表达式怎么学习', '学习正则')).toEqual([]); + }); + }); +}); + diff --git a/tests/SearchStrategies/findLevenshteinMatches.test.ts b/tests/SearchStrategies/findLevenshteinMatches.test.ts new file mode 100644 index 0000000..418a384 --- /dev/null +++ b/tests/SearchStrategies/findLevenshteinMatches.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { findLevenshteinMatches } from '../../src/SearchStrategies/search/findLevenshteinMatches'; + +describe('findLevenshteinMatches', () => { + it('should return matches for identical strings', () => { + const matches = findLevenshteinMatches('hello', 'hello'); + expect(matches).toHaveLength(1); + expect(matches[0].type).toBe('fuzzy'); + expect(matches[0].text).toBe('hello'); + }); + + it('should return matches for strings with small differences (substitutions)', () => { + expect(findLevenshteinMatches('kitten', 'sitting')).toHaveLength(1); + expect(findLevenshteinMatches('flaw', 'lawn')).toHaveLength(1); + }); + + it('should return matches for strings with insertions', () => { + expect(findLevenshteinMatches('cat', 'cats')).toHaveLength(1); + expect(findLevenshteinMatches('hello', 'helloo')).toHaveLength(1); + }); + + it('should return matches for strings with deletions', () => { + expect(findLevenshteinMatches('cats', 'cat')).toHaveLength(1); + expect(findLevenshteinMatches('helloo', 'hello')).toHaveLength(1); + }); + + it('should return empty array for completely different strings (low similarity)', () => { + expect(findLevenshteinMatches('abc', 'xyz')).toEqual([]); + expect(findLevenshteinMatches('abcd', 'wxyz')).toEqual([]); + }); + + it('should handle empty strings', () => { + expect(findLevenshteinMatches('', 'hello')).toEqual([]); + expect(findLevenshteinMatches('hello', '')).toEqual([]); + expect(findLevenshteinMatches('', '')).toEqual([]); + }); + + it('should handle single-character strings', () => { + const matchesIdentical = findLevenshteinMatches('a', 'a'); + expect(matchesIdentical).toHaveLength(1); + expect(matchesIdentical[0].text).toBe('a'); + + expect(findLevenshteinMatches('a', 'b')).toEqual([]); + expect(findLevenshteinMatches('a', '')).toEqual([]); + }); + + it('should handle substitutions correctly', () => { + expect(findLevenshteinMatches('ab', 'ac')).toHaveLength(1); + expect(findLevenshteinMatches('ac', 'bc')).toHaveLength(1); + expect(findLevenshteinMatches('abc', 'axc')).toHaveLength(1); + }); + + it('should handle multiple operations', () => { + expect(findLevenshteinMatches('example', 'samples')).toHaveLength(1); + expect(findLevenshteinMatches('distance', 'eistancd')).toHaveLength(1); + }); + + it('should handle non-Latin characters', () => { + const matches = findLevenshteinMatches('你好世界', '你好'); + expect(matches).toHaveLength(1); + }); + + it('should respect similarity threshold of 30%', () => { + const similarEnough = findLevenshteinMatches('back', 'book'); + expect(similarEnough).toHaveLength(1); + + const notSimilarEnough = findLevenshteinMatches('a', 'zzzzz'); + expect(notSimilarEnough).toEqual([]); + }); + + it('should return match info with correct structure', () => { + const matches = findLevenshteinMatches('hello', 'helo'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ + start: 0, + end: 5, + text: 'hello', + type: 'fuzzy' + }); + }); +}); + diff --git a/tests/SearchStrategies/findLiteralMatches.test.ts b/tests/SearchStrategies/findLiteralMatches.test.ts new file mode 100644 index 0000000..9e731f9 --- /dev/null +++ b/tests/SearchStrategies/findLiteralMatches.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { findLiteralMatches } from '../../src/SearchStrategies/search/findLiteralMatches'; + +describe('findLiteralMatches', () => { + it('does not match a word that is partially contained in the search criteria when followed by a space', () => { + expect(findLiteralMatches('this tasty tester text', 'test ')).toEqual([]); + }); + + it('matches exact single word', () => { + const matches = findLiteralMatches('hello world', 'hello'); + expect(matches).toHaveLength(1); + expect(matches[0].start).toBe(0); + expect(matches[0].end).toBe(5); + expect(matches[0].text).toBe('hello'); + expect(matches[0].type).toBe('exact'); + }); + + it('matches multiple occurrences of the same word', () => { + const matches = findLiteralMatches('hello world hello', 'hello'); + expect(matches).toHaveLength(2); + expect(matches[0].start).toBe(0); + expect(matches[0].end).toBe(5); + expect(matches[1].start).toBe(12); + expect(matches[1].end).toBe(17); + }); + + it('matches multi-word queries when all words present', () => { + const matches = findLiteralMatches('hello amazing world', 'hello world'); + expect(matches.length).toBeGreaterThan(0); + expect(matches.some(m => m.text === 'hello')).toBe(true); + expect(matches.some(m => m.text === 'world')).toBe(true); + }); + + it('does not match when not all words are present', () => { + expect(findLiteralMatches('hello world', 'hello missing')).toEqual([]); + }); + + it('is case insensitive', () => { + const matches = findLiteralMatches('HELLO world', 'hello'); + expect(matches).toHaveLength(1); + expect(matches[0].text).toBe('HELLO'); + }); + + it('handles empty or null inputs', () => { + expect(findLiteralMatches('', 'test')).toEqual([]); + expect(findLiteralMatches('test', '')).toEqual([]); + }); + + it('matches substring within longer text', () => { + const matches = findLiteralMatches('javascript is great', 'script'); + expect(matches).toHaveLength(1); + expect(matches[0].start).toBe(4); + expect(matches[0].end).toBe(10); + }); + + it('handles special characters', () => { + const matches = findLiteralMatches('hello@world.com', '@world'); + expect(matches).toHaveLength(1); + expect(matches[0].text).toBe('@world'); + }); +}); + diff --git a/tests/SearchStrategies/findMatches.test.ts b/tests/SearchStrategies/findMatches.test.ts deleted file mode 100644 index 5d218fa..0000000 --- a/tests/SearchStrategies/findMatches.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { findLiteralMatches, findFuzzyMatches, findWildcardMatches } from '../../src/SearchStrategies/search/findMatches'; - -describe('findMatches Functions', () => { - describe('findLiteralMatches', () => { - it('should find all occurrences of a pattern', () => { - const result = findLiteralMatches('hello world hello', 'hello'); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - start: 0, - end: 5, - text: 'hello', - type: 'exact' - }); - expect(result[1]).toEqual({ - start: 12, - end: 17, - text: 'hello', - type: 'exact' - }); - }); - - it('should handle case insensitive matching', () => { - const result = findLiteralMatches('Hello World', 'hello'); - expect(result).toHaveLength(1); - expect(result[0].text).toBe('Hello'); - expect(result[0].type).toBe('exact'); - }); - - it('should return empty array for no matches', () => { - const result = findLiteralMatches('hello world', 'xyz'); - expect(result).toEqual([]); - }); - - it('should find overlapping patterns', () => { - const result = findLiteralMatches('aaaa', 'aa'); - expect(result).toHaveLength(2); - }); - }); - - describe('findFuzzyMatches', () => { - it('should find fuzzy character sequence match', () => { - const result = findFuzzyMatches('JavaScript', 'java'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('fuzzy'); - expect(result[0].text).toBe('Java'); - }); - - it('should handle character sequence matching', () => { - const result = findFuzzyMatches('hello world', 'hlowrd'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('fuzzy'); - }); - - it('should return empty array for no match', () => { - const result = findFuzzyMatches('hello', 'xyz'); - expect(result).toEqual([]); - }); - - it('should handle empty pattern', () => { - const result = findFuzzyMatches('hello', ''); - expect(result).toEqual([]); - }); - - it('should trim trailing spaces from pattern', () => { - const result = findFuzzyMatches('hello', 'hlo '); - expect(result).toHaveLength(1); - }); - }); - - describe('findWildcardMatches', () => { - it('should find wildcard pattern matches', () => { - const result = findWildcardMatches('hello world', 'hel*world'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('wildcard'); - expect(result[0].text).toBe('hello world'); - }); - - it('should find multiple wildcard matches', () => { - const result = findWildcardMatches('test test test', 'te*t'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('wildcard'); - }); - - it('should return empty array for no matches', () => { - const result = findWildcardMatches('hello', 'xyz*'); - expect(result).toEqual([]); - }); - - it('should handle simple wildcard patterns', () => { - const result = findWildcardMatches('hello', 'hel*'); - expect(result).toHaveLength(1); - expect(result[0].text).toBe('hello'); - }); - }); - - describe('Consistency between boolean and findMatches functions', () => { - it('should be consistent for literal search', () => { - const text = 'hello world'; - const criteria = 'world'; - const matches = findLiteralMatches(text, criteria); - expect(matches.length > 0).toBe(true); - }); - - it('should be consistent for fuzzy search', () => { - const text = 'JavaScript'; - const criteria = 'java'; - const matches = findFuzzyMatches(text, criteria); - expect(matches.length > 0).toBe(true); - }); - }); -}); - diff --git a/tests/SearchStrategies/findWildcardMatches.test.ts b/tests/SearchStrategies/findWildcardMatches.test.ts new file mode 100644 index 0000000..e871fc3 --- /dev/null +++ b/tests/SearchStrategies/findWildcardMatches.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { findWildcardMatches } from '../../src/SearchStrategies/search/findWildcardMatches'; + +describe('findWildcardMatches', () => { + it('should return matches for exact matches', () => { + const matches = findWildcardMatches('hello', 'hello'); + expect(matches).toHaveLength(1); + expect(matches[0].type).toBe('wildcard'); + }); + + it('should return matches for patterns with wildcards', () => { + expect(findWildcardMatches('hello', 'he*o')).toHaveLength(1); + expect(findWildcardMatches('hello', 'he*o*')).toHaveLength(1); + expect(findWildcardMatches('test', 'te*t')).toHaveLength(1); + expect(findWildcardMatches('text', 'te*t')).toHaveLength(1); + }); + + it('should match multiple words with wildcards', () => { + expect(findWildcardMatches('hello amazing world', 'hello*world')).toHaveLength(1); + expect(findWildcardMatches('hello world', 'hello*world')).toHaveLength(1); + expect(findWildcardMatches('hello world', 'hello*')).toHaveLength(1); + }); + + it.skip('should return matches for fuzzy matches with high similarity', () => { + expect(findWildcardMatches('hello', 'helo')).toHaveLength(1); + expect(findWildcardMatches('hello', 'hell')).toHaveLength(1); + }); + + it('should return empty array for non-matching wildcard patterns', () => { + expect(findWildcardMatches('world', 'h*o')).toEqual([]); + expect(findWildcardMatches('xyz', 'abc')).toEqual([]); + }); + + it('should handle single-character patterns and texts', () => { + expect(findWildcardMatches('a', 'a')).toHaveLength(1); + expect(findWildcardMatches('b', 'a')).toEqual([]); + const starMatches = findWildcardMatches('a', '*'); + expect(starMatches.length).toBeGreaterThanOrEqual(1); + }); + + it('should return empty array for a word not present in the text', () => { + expect(findWildcardMatches('hello world', 'missing')).toEqual([]); + expect(findWildcardMatches('hello world', 'miss*')).toEqual([]); + }); + + it('should return match info with correct positions', () => { + const matches = findWildcardMatches('hello', 'hello'); + expect(matches[0].start).toBe(0); + expect(matches[0].end).toBe(5); + expect(matches[0].text).toBe('hello'); + }); + + it('should handle wildcards at beginning and end', () => { + expect(findWildcardMatches('hello world', '*world')).toHaveLength(1); + expect(findWildcardMatches('hello world', 'hello*')).toHaveLength(1); + expect(findWildcardMatches('hello world', '*llo wor*')).toHaveLength(1); + }); +}); + diff --git a/tests/SearchStrategies/fuzzySearch.test.ts b/tests/SearchStrategies/fuzzySearch.test.ts deleted file mode 100644 index 8e893d7..0000000 --- a/tests/SearchStrategies/fuzzySearch.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { fuzzySearch } from '../../src/SearchStrategies/search/fuzzySearch'; - -describe('fuzzySearch', () => { - it('matches exact strings', () => { - expect(fuzzySearch('hello', 'hello')).toBe(true); - expect(fuzzySearch('test', 'test')).toBe(true); - }); - - it('matches substrings', () => { - expect(fuzzySearch('hello', 'hlo')).toBe(true); - expect(fuzzySearch('test', 'tst')).toBe(true); - expect(fuzzySearch('fuzzy', 'fzy')).toBe(true); - expect(fuzzySearch('react', 'rct')).toBe(true); - expect(fuzzySearch('what the heck', 'wth')).toBe(true); - }); - - it('matches characters in sequence', () => { - expect(fuzzySearch('hello world', 'hw')).toBe(true); - expect(fuzzySearch('a1b2c3', 'abc')).toBe(true); - }); - - it('does not match out-of-sequence characters', () => { - expect(fuzzySearch('abc', 'cba')).toBe(false); - expect(fuzzySearch('abcd', 'dc')).toBe(false); - }); - - it('does not match words that don\'t contain the search criteria', () => { - expect(fuzzySearch('fuzzy', 'fzyyy')).toBe(false); - expect(fuzzySearch('react', 'angular')).toBe(false); - expect(fuzzySearch('what the heck', 'wth?')).toBe(false); - }); - - it('is case insensitive', () => { - expect(fuzzySearch('HELLO', 'hello')).toBe(true); - expect(fuzzySearch('world', 'WORLD')).toBe(true); - expect(fuzzySearch('hEllO', 'HeLLo')).toBe(true); - expect(fuzzySearch('Different Cases', 'dc')).toBe(true); - expect(fuzzySearch('UPPERCASE', 'upprcs')).toBe(true); - expect(fuzzySearch('lowercase', 'lc')).toBe(true); - expect(fuzzySearch('DiFfErENt cASeS', 'dc')).toBe(true); - }); - - it('handles special characters', () => { - expect(fuzzySearch('hello!@#$', 'h!@#$')).toBe(true); - expect(fuzzySearch('abc123xyz', '123')).toBe(true); - }); - - it('handles spaces correctly', () => { - expect(fuzzySearch('hello world', 'hw')).toBe(true); - expect(fuzzySearch('hello world', 'h w')).toBe(true); - expect(fuzzySearch('hello world', 'hw ')).toBe(true); - }); - - it('matches characters in sequence', () => { - expect(fuzzySearch('hello world', 'hlo wld')).toBe(true); - expect(fuzzySearch('hello world', 'hw')).toBe(true); - expect(fuzzySearch('hello world', 'hlowrd')).toBe(true); - expect(fuzzySearch('hello world', 'wrld')).toBe(true); - expect(fuzzySearch('hello world', 'wh')).toBe(false); - }); - - it('does not match when character frequency in the pattern exceeds the text', () => { - expect(fuzzySearch('goggles', 'gggggggg')).toBe(false); - expect(fuzzySearch('aab', 'aaaa')).toBe(false); - }); - - it('match ordered multiple words', () => { - expect(fuzzySearch('Ola que tal', 'ola tal')).toBe(true); - }); - - describe('original fuzzysearch test cases', () => { - it('matches cartwheel test cases', () => { - expect(fuzzySearch('cartwheel', 'car')).toBe(true); - expect(fuzzySearch('cartwheel', 'cwhl')).toBe(true); - expect(fuzzySearch('cartwheel', 'cwheel')).toBe(true); - expect(fuzzySearch('cartwheel', 'cartwheel')).toBe(true); - expect(fuzzySearch('cartwheel', 'cwheeel')).toBe(false); - expect(fuzzySearch('cartwheel', 'lw')).toBe(false); - }); - - it('matches Chinese Unicode test cases', () => { - expect(fuzzySearch('php语言', '语言')).toBe(true); - expect(fuzzySearch('php语言', 'hp语')).toBe(true); - expect(fuzzySearch('Python开发者', 'Py开发')).toBe(true); - expect(fuzzySearch('Python开发者', 'Py 开发')).toBe(false); - expect(fuzzySearch('爪哇开发进阶', '爪哇进阶')).toBe(true); - expect(fuzzySearch('非常简单的格式化工具', '格式工具')).toBe(true); - expect(fuzzySearch('学习正则表达式怎么学习', '正则')).toBe(true); - expect(fuzzySearch('正则表达式怎么学习', '学习正则')).toBe(false); - }); - }); -}); \ No newline at end of file diff --git a/tests/SearchStrategies/levenshtein.test.ts b/tests/SearchStrategies/levenshtein.test.ts deleted file mode 100644 index c9e9943..0000000 --- a/tests/SearchStrategies/levenshtein.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { levenshtein } from '../../src/SearchStrategies/search/levenshtein'; - - -describe('levenshtein', () => { - - /** - * The distance Matrix - * - * "" b a c k - * "" 0 1 2 3 4 - * b 1 0 1 2 3 - * o 2 1 1 2 3 - * o 3 2 2 2 3 - * k 4 3 3 3 2 - * - * difference between bo and ba is 1 so Matrix[2][2] = 1 - * - * Result is Matrix["book".length]["back".length] = 2 - */ - it('should return the right difference', () => { - expect(levenshtein('back', 'book')).toBe(2); - }); - - it('should return 0 for identical strings', () => { - expect(levenshtein('hello', 'hello')).toBe(0); - }); - - it('should return the correct distance for strings with substitutions', () => { - expect(levenshtein('kitten', 'sitting')).toBe(3); - expect(levenshtein('flaw', 'lawn')).toBe(2); - }); - - it('should return the correct distance for strings with insertions', () => { - expect(levenshtein('cat', 'cats')).toBe(1); - expect(levenshtein('hello', 'helloo')).toBe(1); - }); - - it('should return the correct distance for strings with deletions', () => { - expect(levenshtein('cats', 'cat')).toBe(1); - expect(levenshtein('helloo', 'hello')).toBe(1); - }); - - it('should return the correct distance for completely different strings', () => { - expect(levenshtein('abc', 'xyz')).toBe(3); - expect(levenshtein('abcd', 'wxyz')).toBe(4); - }); - - it('should handle empty strings correctly', () => { - expect(levenshtein('', 'hello')).toBe(5); - expect(levenshtein('hello', '')).toBe(5); - expect(levenshtein('', '')).toBe(0); - }); - - it('should handle single-character strings correctly', () => { - expect(levenshtein('a', 'b')).toBe(1); - expect(levenshtein('a', 'a')).toBe(0); - expect(levenshtein('a', '')).toBe(1); - }); - - it('should handle substitutions correctly', () => { - expect(levenshtein('ab', 'ac')).toBe(1); - expect(levenshtein('ac', 'bc')).toBe(1); - expect(levenshtein('abc', 'axc')).toBe(1); - expect(levenshtein('xabxcdxxefxgx', '1ab2cd34ef5g6')).toBe(6); - }); - - it('should handle multiple operations correctly', () => { - expect(levenshtein('xabxcdxxefxgx', 'abcdefg')).toBe(6); - expect(levenshtein('javawasneat', 'scalaisgreat')).toBe(7); - expect(levenshtein('example', 'samples')).toBe(3); - expect(levenshtein('forward', 'drawrof')).toBe(6); - expect(levenshtein('sturgeon', 'urgently')).toBe(6); - expect(levenshtein('levenshtein', 'frankenstein')).toBe(6); - expect(levenshtein('distance', 'difference')).toBe(5); - expect(levenshtein('distance', 'eistancd')).toBe(2); - }); - - it('should handle non-Latin characters correctly', () => { - expect(levenshtein('你好世界', '你好')).toBe(2); // Chinese - expect(levenshtein('因為我是中國人所以我會說中文', '因為我是英國人所以我會說英文')).toBe(2); // Chinese - }); -}); \ No newline at end of file diff --git a/tests/SearchStrategies/literalSearch.test.ts b/tests/SearchStrategies/literalSearch.test.ts deleted file mode 100644 index e46569f..0000000 --- a/tests/SearchStrategies/literalSearch.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { literalSearch } from '../../src/SearchStrategies/search/literalSearch'; - -describe('literalSearch', () => { - - it('does not match a word that is partially contained in the search criteria when followed by a space', () => { - expect(literalSearch('this tasty tester text', 'test ')).toBe(false); - }); -}); \ No newline at end of file diff --git a/tests/SearchStrategies/wildcardSearch.test.ts b/tests/SearchStrategies/wildcardSearch.test.ts deleted file mode 100644 index 2a4a83b..0000000 --- a/tests/SearchStrategies/wildcardSearch.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { wildcardSearch } from '../../src/SearchStrategies/search/wildcardSearch'; - -describe('wildcardSearch', () => { - it('should return true for exact matches', () => { - expect(wildcardSearch('hello', 'hello')).toBe(true); - }); - - it('should return true for matches with wildcards', () => { - expect(wildcardSearch('hello', 'he*o')).toBe(true); - expect(wildcardSearch('hello', 'he*o*')).toBe(true); - expect(wildcardSearch('test', 'te*t')).toBe(true); - expect(wildcardSearch('text', 'te*t')).toBe(true); - }); - - it('should match multiple words with wildcards', () => { - expect(wildcardSearch('hello amazing world', 'hello*world')).toBe(true); - expect(wildcardSearch('hello world', 'hello*world')).toBe(true); - expect(wildcardSearch('hello world', 'hello*')).toBe(true); - }); - - // Wildcard search does not support fuzzy matching - it.skip('should return true for fuzzy matches with high similarity', () => { - expect(wildcardSearch('hello', 'helo')).toBe(true); - expect(wildcardSearch('hello', 'hell')).toBe(true); - }); - - it('should return false for non-matching wildcard patterns', () => { - expect(wildcardSearch('world', 'h*o')).toBe(false); - expect(wildcardSearch('xyz', 'abc')).toBe(false); - }); - - it('should handle single-character patterns and texts', () => { - expect(wildcardSearch('a', 'a')).toBe(true); - expect(wildcardSearch('b', 'a')).toBe(false); - expect(wildcardSearch('a', '*')).toBe(true); - }); - - it('should return false for a word not present in the text', () => { - expect(wildcardSearch('hello world', 'missing')).toBe(false); - expect(wildcardSearch('hello world', 'miss*')).toBe(false); - }); -}); \ No newline at end of file diff --git a/tests/performance/benchmark.test.ts b/tests/performance/benchmark.test.ts index 585f66d..d2cdef3 100644 --- a/tests/performance/benchmark.test.ts +++ b/tests/performance/benchmark.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { SearchStrategy } from '../../src/SearchStrategies/types'; -import { PerformanceMonitor } from '../../src/utils/PerformanceMonitor'; +import { PerformanceMonitor } from '../utils/PerformanceMonitor'; describe('Performance Benchmarks', () => { const sampleTexts = [ diff --git a/src/utils/PerformanceMonitor.ts b/tests/utils/PerformanceMonitor.ts similarity index 100% rename from src/utils/PerformanceMonitor.ts rename to tests/utils/PerformanceMonitor.ts From 7626771e099626d23ef7524e3b8fcc89f91f7fd2 Mon Sep 17 00:00:00 2001 From: sylhare Date: Thu, 16 Oct 2025 13:51:01 -0400 Subject: [PATCH 06/18] Add strategy factory --- src/Repository.ts | 6 +- src/SearchStrategies/HybridSearchStrategy.ts | 52 ++++++ src/SearchStrategies/StrategyFactory.ts | 44 +++++ src/index.ts | 5 + src/utils/types.ts | 2 +- .../HybridSearchStrategy.test.ts | 175 ++++++++++++++++++ .../SearchStrategies/StrategyFactory.test.ts | 105 +++++++++++ 7 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 src/SearchStrategies/HybridSearchStrategy.ts create mode 100644 src/SearchStrategies/StrategyFactory.ts create mode 100644 tests/SearchStrategies/HybridSearchStrategy.test.ts create mode 100644 tests/SearchStrategies/StrategyFactory.test.ts diff --git a/src/Repository.ts b/src/Repository.ts index 0527cee..50e1853 100644 --- a/src/Repository.ts +++ b/src/Repository.ts @@ -1,5 +1,6 @@ import { FuzzySearchStrategy, LiteralSearchStrategy, WildcardSearchStrategy } from './SearchStrategies/SearchStrategy'; import { Matcher } from './SearchStrategies/types'; +import { StrategyFactory, StrategyType } from './SearchStrategies/StrategyFactory'; import { clone, isObject } from './utils'; import { DEFAULT_OPTIONS } from './utils/default'; import { RepositoryData, RepositoryOptions } from './utils/types'; @@ -103,8 +104,11 @@ export class Repository { } private searchStrategy( - strategy: 'literal' | 'fuzzy' | 'wildcard', + strategy: StrategyType, ): Matcher { + if (StrategyFactory.isValidStrategy(strategy)) { + return StrategyFactory.create(strategy); + } switch (strategy) { case 'fuzzy': return FuzzySearchStrategy; diff --git a/src/SearchStrategies/HybridSearchStrategy.ts b/src/SearchStrategies/HybridSearchStrategy.ts new file mode 100644 index 0000000..a31fe11 --- /dev/null +++ b/src/SearchStrategies/HybridSearchStrategy.ts @@ -0,0 +1,52 @@ +import { SearchStrategy, MatchInfo } from './types'; +import { findLiteralMatches } from './search/findLiteralMatches'; +import { findFuzzyMatches } from './search/findFuzzyMatches'; +import { findWildcardMatches } from './search/findWildcardMatches'; + +export interface HybridConfig { + preferFuzzy?: boolean; + wildcardPriority?: boolean; + minFuzzyLength?: number; +} + +export class HybridSearchStrategy extends SearchStrategy { + private config: Required; + + constructor(config: HybridConfig = {}) { + super((text: string, criteria: string) => { + return this.hybridFind(text, criteria); + }); + + this.config = { + preferFuzzy: config.preferFuzzy ?? false, + wildcardPriority: config.wildcardPriority ?? true, + minFuzzyLength: config.minFuzzyLength ?? 3, + }; + } + + private hybridFind(text: string, criteria: string): MatchInfo[] { + if (this.config.wildcardPriority && criteria.includes('*')) { + const wildcardMatches = findWildcardMatches(text, criteria); + if (wildcardMatches.length > 0) return wildcardMatches; + } + + if (criteria.includes(' ') || criteria.length < this.config.minFuzzyLength) { + const literalMatches = findLiteralMatches(text, criteria); + if (literalMatches.length > 0) return literalMatches; + } + + if (this.config.preferFuzzy || criteria.length >= this.config.minFuzzyLength) { + const fuzzyMatches = findFuzzyMatches(text, criteria); + if (fuzzyMatches.length > 0) return fuzzyMatches; + } + + return findLiteralMatches(text, criteria); + } + + getConfig(): Readonly> { + return { ...this.config }; + } +} + +export const DefaultHybridSearchStrategy = new HybridSearchStrategy(); + diff --git a/src/SearchStrategies/StrategyFactory.ts b/src/SearchStrategies/StrategyFactory.ts new file mode 100644 index 0000000..128dcdd --- /dev/null +++ b/src/SearchStrategies/StrategyFactory.ts @@ -0,0 +1,44 @@ +import { SearchStrategy } from './types'; +import { LiteralSearchStrategy, FuzzySearchStrategy, WildcardSearchStrategy } from './SearchStrategy'; +import { HybridSearchStrategy, HybridConfig } from './HybridSearchStrategy'; + +export type StrategyType = 'literal' | 'fuzzy' | 'wildcard' | 'hybrid'; + +export interface StrategyConfig { + type: StrategyType; + hybridConfig?: HybridConfig; +} + +export class StrategyFactory { + static create(config: StrategyConfig | StrategyType): SearchStrategy { + if (typeof config === 'string') { + config = { type: config }; + } + + switch (config.type) { + case 'literal': + return LiteralSearchStrategy; + + case 'fuzzy': + return FuzzySearchStrategy; + + case 'wildcard': + return WildcardSearchStrategy; + + case 'hybrid': + return new HybridSearchStrategy(config.hybridConfig); + + default: + return LiteralSearchStrategy; + } + } + + static getAvailableStrategies(): StrategyType[] { + return ['literal', 'fuzzy', 'wildcard', 'hybrid']; + } + + static isValidStrategy(type: string): type is StrategyType { + return this.getAvailableStrategies().includes(type as StrategyType); + } +} + diff --git a/src/index.ts b/src/index.ts index 60347a0..8804ac0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,11 @@ export type { HighlightOptions } from './middleware/highlighting'; export { highlightWithMatchInfo, escapeHtml, mergeOverlappingMatches } from './middleware/highlighting'; export { createHighlightTemplateMiddleware, defaultHighlightMiddleware } from './middleware/highlightMiddleware'; +export { HybridSearchStrategy, DefaultHybridSearchStrategy } from './SearchStrategies/HybridSearchStrategy'; +export type { HybridConfig } from './SearchStrategies/HybridSearchStrategy'; +export { StrategyFactory } from './SearchStrategies/StrategyFactory'; +export type { StrategyType, StrategyConfig } from './SearchStrategies/StrategyFactory'; + // Add to window if in browser environment if (typeof window !== 'undefined') { (window as any).SimpleJekyllSearch = SimpleJekyllSearch; diff --git a/src/utils/types.ts b/src/utils/types.ts index 6fbc6a5..cc93a52 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -18,7 +18,7 @@ export interface SearchData { export interface RepositoryOptions { /** @deprecated Use strategy instead (e.g. `strategy: 'fuzzy'`) */ fuzzy?: boolean; - strategy?: 'literal' | 'fuzzy' | 'wildcard'; + strategy?: 'literal' | 'fuzzy' | 'wildcard' | 'hybrid'; limit?: number; searchStrategy?: Matcher; sortMiddleware?: (a: any, b: any) => number; diff --git a/tests/SearchStrategies/HybridSearchStrategy.test.ts b/tests/SearchStrategies/HybridSearchStrategy.test.ts new file mode 100644 index 0000000..2d8ab24 --- /dev/null +++ b/tests/SearchStrategies/HybridSearchStrategy.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { HybridSearchStrategy } from '../../src/SearchStrategies/HybridSearchStrategy'; + +describe('HybridSearchStrategy', () => { + describe('wildcard detection', () => { + const strategy = new HybridSearchStrategy(); + + it('should use wildcard search when * is present', () => { + const matches = strategy.findMatches('hello world', 'hel*rld'); + expect(matches).toHaveLength(1); + expect(matches[0].type).toBe('wildcard'); + }); + + it('should use wildcard search for multiple * patterns', () => { + const matches = strategy.findMatches('hello amazing world', 'hello*world'); + expect(matches).toHaveLength(1); + expect(matches[0].type).toBe('wildcard'); + }); + + it('should fall back to literal if wildcard has no match', () => { + const matches = strategy.findMatches('hello world', 'xyz*abc'); + expect(matches).toEqual([]); + }); + }); + + describe('multi-word detection', () => { + const strategy = new HybridSearchStrategy(); + + it('should use literal search for multi-word queries', () => { + const matches = strategy.findMatches('hello amazing world', 'hello world'); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].type).toBe('exact'); + }); + + it('should find all words in multi-word search', () => { + const matches = strategy.findMatches('test this amazing test', 'test amazing'); + expect(matches.length).toBeGreaterThan(0); + }); + + it('should not match if any word is missing', () => { + const matches = strategy.findMatches('hello world', 'hello missing'); + expect(matches).toEqual([]); + }); + }); + + describe('fuzzy fallback', () => { + const strategy = new HybridSearchStrategy(); + + it('should use fuzzy search for single-word queries >= minFuzzyLength', () => { + const matches = strategy.findMatches('javascript', 'jvscrpt'); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].type).toBe('fuzzy'); + }); + + it('should use fuzzy for long single words', () => { + const matches = strategy.findMatches('development', 'dvlpmnt'); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].type).toBe('fuzzy'); + }); + }); + + describe('short query handling', () => { + const strategy = new HybridSearchStrategy(); + + it('should use literal search for queries < minFuzzyLength', () => { + const matches = strategy.findMatches('hello', 'he'); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].type).toBe('exact'); + }); + + it('should use literal for 2-character queries', () => { + const matches = strategy.findMatches('ab cd ef', 'ab'); + expect(matches.length).toBeGreaterThan(0); + }); + }); + + describe('configuration', () => { + it('should respect minFuzzyLength config', () => { + const customStrategy = new HybridSearchStrategy({ minFuzzyLength: 5 }); + const matches = customStrategy.findMatches('test', 'test'); + expect(matches.length).toBeGreaterThan(0); + }); + + it('should respect preferFuzzy config', () => { + const fuzzyPreferred = new HybridSearchStrategy({ preferFuzzy: true }); + const matches = fuzzyPreferred.findMatches('testing', 'tsting'); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].type).toBe('fuzzy'); + }); + + it('should respect wildcardPriority = false', () => { + const noWildcardPriority = new HybridSearchStrategy({ wildcardPriority: false }); + const matches = noWildcardPriority.findMatches('hello world', 'hello'); + expect(matches.length).toBeGreaterThan(0); + }); + + it('should return config via getConfig()', () => { + const strategy = new HybridSearchStrategy({ minFuzzyLength: 5, preferFuzzy: true }); + const config = strategy.getConfig(); + expect(config.minFuzzyLength).toBe(5); + expect(config.preferFuzzy).toBe(true); + expect(config.wildcardPriority).toBe(true); + }); + }); + + describe('fallback chain', () => { + const strategy = new HybridSearchStrategy(); + + it('should fall back to literal when wildcard fails', () => { + const matches = strategy.findMatches('hello world', 'world'); + expect(matches.length).toBeGreaterThan(0); + }); + + it('should fall back to literal when fuzzy fails', () => { + const matches = strategy.findMatches('abc', 'xyz'); + expect(matches).toEqual([]); + }); + + it('should try all strategies in order', () => { + const strategy = new HybridSearchStrategy(); + const matches = strategy.findMatches('hello world', 'hello'); + expect(matches.length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + const strategy = new HybridSearchStrategy(); + + it('should handle empty strings', () => { + const matches = strategy.findMatches('', 'test'); + expect(matches).toEqual([]); + }); + + it('should handle special characters', () => { + const matches = strategy.findMatches('test@example.com', '@example'); + expect(matches.length).toBeGreaterThan(0); + }); + + it('should handle unicode characters', () => { + const matches = strategy.findMatches('你好世界', '你好'); + expect(matches.length).toBeGreaterThan(0); + }); + }); + + describe('cache integration', () => { + const strategy = new HybridSearchStrategy(); + + it('should use cache for repeated searches', () => { + const matches1 = strategy.findMatches('hello world', 'hello'); + const matches2 = strategy.findMatches('hello world', 'hello'); + expect(matches1).toEqual(matches2); + }); + + it('should clear cache', () => { + strategy.findMatches('test', 'test'); + strategy.clearCache(); + const stats = strategy.getCacheStats(); + expect(stats.size).toBe(0); + }); + }); + + describe('matches() method', () => { + const strategy = new HybridSearchStrategy(); + + it('should return true for valid matches', () => { + expect(strategy.matches('hello world', 'hello')).toBe(true); + expect(strategy.matches('test', 'te*t')).toBe(true); + }); + + it('should return false for no matches', () => { + expect(strategy.matches('hello', 'xyz')).toBe(false); + }); + }); +}); + diff --git a/tests/SearchStrategies/StrategyFactory.test.ts b/tests/SearchStrategies/StrategyFactory.test.ts new file mode 100644 index 0000000..8d6a8cd --- /dev/null +++ b/tests/SearchStrategies/StrategyFactory.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { StrategyFactory } from '../../src/SearchStrategies/StrategyFactory'; +import { LiteralSearchStrategy, FuzzySearchStrategy, WildcardSearchStrategy } from '../../src/SearchStrategies/SearchStrategy'; +import { HybridSearchStrategy } from '../../src/SearchStrategies/HybridSearchStrategy'; + +describe('StrategyFactory', () => { + describe('create with string type', () => { + it('should create literal strategy', () => { + const strategy = StrategyFactory.create('literal'); + expect(strategy).toBe(LiteralSearchStrategy); + }); + + it('should create fuzzy strategy', () => { + const strategy = StrategyFactory.create('fuzzy'); + expect(strategy).toBe(FuzzySearchStrategy); + }); + + it('should create wildcard strategy', () => { + const strategy = StrategyFactory.create('wildcard'); + expect(strategy).toBe(WildcardSearchStrategy); + }); + + it('should create hybrid strategy', () => { + const strategy = StrategyFactory.create('hybrid'); + expect(strategy).toBeInstanceOf(HybridSearchStrategy); + }); + }); + + describe('create with config object', () => { + it('should create strategy from config', () => { + const strategy = StrategyFactory.create({ type: 'literal' }); + expect(strategy).toBe(LiteralSearchStrategy); + }); + + it('should pass hybrid config', () => { + const strategy = StrategyFactory.create({ + type: 'hybrid', + hybridConfig: { minFuzzyLength: 5 } + }); + expect(strategy).toBeInstanceOf(HybridSearchStrategy); + const config = (strategy as HybridSearchStrategy).getConfig(); + expect(config.minFuzzyLength).toBe(5); + }); + + }); + + describe('error handling', () => { + + it('should default to literal for unknown type', () => { + const strategy = StrategyFactory.create({ type: 'unknown' as any }); + expect(strategy).toBe(LiteralSearchStrategy); + }); + }); + + describe('getAvailableStrategies', () => { + it('should return all available strategy types', () => { + const strategies = StrategyFactory.getAvailableStrategies(); + expect(strategies).toContain('literal'); + expect(strategies).toContain('fuzzy'); + expect(strategies).toContain('wildcard'); + expect(strategies).toContain('hybrid'); + expect(strategies).toHaveLength(4); + }); + }); + + describe('isValidStrategy', () => { + it('should return true for valid strategies', () => { + expect(StrategyFactory.isValidStrategy('literal')).toBe(true); + expect(StrategyFactory.isValidStrategy('fuzzy')).toBe(true); + expect(StrategyFactory.isValidStrategy('wildcard')).toBe(true); + expect(StrategyFactory.isValidStrategy('hybrid')).toBe(true); + }); + + it('should return false for invalid strategies', () => { + expect(StrategyFactory.isValidStrategy('unknown')).toBe(false); + expect(StrategyFactory.isValidStrategy('custom')).toBe(false); + expect(StrategyFactory.isValidStrategy('')).toBe(false); + }); + }); + + describe('strategy functionality', () => { + it('should create working literal strategy', () => { + const strategy = StrategyFactory.create('literal'); + expect(strategy.matches('hello world', 'hello')).toBe(true); + }); + + it('should create working fuzzy strategy', () => { + const strategy = StrategyFactory.create('fuzzy'); + const matches = strategy.findMatches('hello', 'hlo'); + expect(matches.length).toBeGreaterThan(0); + }); + + it('should create working wildcard strategy', () => { + const strategy = StrategyFactory.create('wildcard'); + expect(strategy.matches('hello world', 'hel*rld')).toBe(true); + }); + + it('should create working hybrid strategy', () => { + const strategy = StrategyFactory.create('hybrid'); + expect(strategy.matches('hello world', 'hello')).toBe(true); + expect(strategy.matches('test', 'te*t')).toBe(true); + }); + }); +}); + From 00d9824fb42235707a436d8f268b5b3538a518e4 Mon Sep 17 00:00:00 2001 From: sylhare Date: Fri, 17 Oct 2025 09:43:05 -0400 Subject: [PATCH 07/18] Deprecate fuzzy --- dest/simple-jekyll-search.js | 460 ++++++++++++++++++--- docs/assets/js/simple-jekyll-search.min.js | 2 +- src/Repository.ts | 14 +- src/SimpleJekyllSearch.ts | 1 - src/utils/default.ts | 4 +- tests/Repository.test.ts | 11 +- 6 files changed, 421 insertions(+), 71 deletions(-) diff --git a/dest/simple-jekyll-search.js b/dest/simple-jekyll-search.js index ce5759d..26d2007 100644 --- a/dest/simple-jekyll-search.js +++ b/dest/simple-jekyll-search.js @@ -48,77 +48,285 @@ 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 findLiteralMatches(text, criteria) { + const lowerText = text.trim().toLowerCase(); + const pattern = criteria.endsWith(" ") ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(" "); + const wordsFound = pattern.filter((word) => lowerText.indexOf(word) >= 0).length; + if (wordsFound !== pattern.length) { + return []; + } + const matches = []; + for (const word of pattern) { + let startIndex = 0; + while ((startIndex = lowerText.indexOf(word, startIndex)) !== -1) { + matches.push({ + start: startIndex, + end: startIndex + word.length, + text: text.substring(startIndex, startIndex + word.length), + type: "exact" + }); + startIndex += word.length; } - 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, - // 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 - ); - } - } - return distanceMatrix[lenA][lenB]; + return matches; } - function levenshteinSearch(text, pattern) { - const distance = levenshtein(pattern, text); - const similarity = 1 - distance / Math.max(pattern.length, text.length); - return similarity >= 0.3; + function findFuzzyMatches(text, criteria) { + criteria = criteria.trimEnd(); + if (criteria.length === 0) return []; + const lowerText = text.toLowerCase(); + const lowerCriteria = criteria.toLowerCase(); + let textIndex = 0; + let criteriaIndex = 0; + const matchedIndices = []; + while (textIndex < text.length && criteriaIndex < criteria.length) { + if (lowerText[textIndex] === lowerCriteria[criteriaIndex]) { + matchedIndices.push(textIndex); + criteriaIndex++; + } + textIndex++; + } + if (criteriaIndex !== criteria.length) { + return []; + } + if (matchedIndices.length === 0) { + return []; + } + const start = matchedIndices[0]; + const end = matchedIndices[matchedIndices.length - 1] + 1; + return [{ + start, + end, + text: text.substring(start, end), + type: "fuzzy" + }]; } - function wildcardSearch(text, pattern) { + function findWildcardMatches(text, pattern) { 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"); + const matches = []; + let match; + while ((match = regex.exec(text)) !== null) { + matches.push({ + start: match.index, + end: match.index + match[0].length, + text: match[0], + type: "wildcard" + }); + if (regex.lastIndex === match.index) { + regex.lastIndex++; + } + } + return matches; + } + class SearchCache { + constructor(options2 = {}) { + this.cache = /* @__PURE__ */ new Map(); + this.hitCount = 0; + this.missCount = 0; + this.options = { + maxSize: options2.maxSize || 1e3, + ttl: options2.ttl || 6e4 + }; + } + get(key) { + const entry = this.cache.get(key); + if (!entry) { + this.missCount++; + return void 0; + } + if (Date.now() - entry.timestamp > this.options.ttl) { + this.cache.delete(key); + this.missCount++; + return void 0; + } + entry.hits++; + this.hitCount++; + return entry.value; + } + set(key, value) { + if (this.cache.size >= this.options.maxSize) { + this.evictOldest(); + } + this.cache.set(key, { + value, + timestamp: Date.now(), + hits: 0 + }); + } + clear() { + this.cache.clear(); + this.hitCount = 0; + this.missCount = 0; + } + evictOldest() { + let oldestKey; + let lowestScore = Infinity; + for (const [key, entry] of this.cache) { + const score = entry.timestamp + entry.hits * 1e4; + if (score < lowestScore) { + lowestScore = score; + oldestKey = key; + } + } + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + getStats() { + const total = this.hitCount + this.missCount; + const hitRate = total > 0 ? this.hitCount / total : 0; + return { + size: this.cache.size, + maxSize: this.options.maxSize, + ttl: this.options.ttl, + hits: this.hitCount, + misses: this.missCount, + hitRate: Math.round(hitRate * 1e4) / 100 + }; + } + has(key) { + const entry = this.cache.get(key); + if (!entry) return false; + if (Date.now() - entry.timestamp > this.options.ttl) { + this.cache.delete(key); + return false; + } + return true; + } } class SearchStrategy { - constructor(matchFunction) { - this.matchFunction = matchFunction; + constructor(findMatchesFunction) { + this.findMatchesFunction = findMatchesFunction; + this.cache = new SearchCache({ maxSize: 500, ttl: 6e4 }); } matches(text, criteria) { if (text === null || text.trim() === "" || !criteria) { return false; } - return this.matchFunction(text, criteria); + const cacheKey = this.getCacheKey(text, criteria); + const cached = this.cache.get(cacheKey); + if (cached !== void 0) { + return cached.matches; + } + const matchInfo = this.findMatchesInternal(text, criteria); + const result = { + matches: matchInfo.length > 0, + matchInfo + }; + this.cache.set(cacheKey, result); + return result.matches; + } + findMatches(text, criteria) { + if (text === null || text.trim() === "" || !criteria) { + return []; + } + const cacheKey = this.getCacheKey(text, criteria); + const cached = this.cache.get(cacheKey); + if (cached !== void 0) { + return cached.matchInfo; + } + const matchInfo = this.findMatchesInternal(text, criteria); + const result = { + matches: matchInfo.length > 0, + matchInfo + }; + this.cache.set(cacheKey, result); + return result.matchInfo; + } + findMatchesInternal(text, criteria) { + return this.findMatchesFunction(text, criteria); + } + getCacheKey(text, criteria) { + return `${text.length}:${criteria}:${text.substring(0, 20)}`; + } + clearCache() { + this.cache.clear(); + } + getCacheStats() { + const stats = this.cache.getStats(); + return { + hitRate: stats.hitRate, + size: stats.size + }; + } + } + const LiteralSearchStrategy = new SearchStrategy( + findLiteralMatches + ); + const FuzzySearchStrategy = new SearchStrategy( + (text, criteria) => { + const fuzzyMatches = findFuzzyMatches(text, criteria); + if (fuzzyMatches.length > 0) { + return fuzzyMatches; + } + return findLiteralMatches(text, criteria); + } + ); + const WildcardSearchStrategy = new SearchStrategy( + (text, criteria) => { + const wildcardMatches = findWildcardMatches(text, criteria); + if (wildcardMatches.length > 0) { + return wildcardMatches; + } + return findLiteralMatches(text, criteria); + } + ); + class HybridSearchStrategy extends SearchStrategy { + constructor(config = {}) { + super((text, criteria) => { + return this.hybridFind(text, criteria); + }); + this.config = { + preferFuzzy: config.preferFuzzy ?? false, + wildcardPriority: config.wildcardPriority ?? true, + minFuzzyLength: config.minFuzzyLength ?? 3 + }; + } + hybridFind(text, criteria) { + if (this.config.wildcardPriority && criteria.includes("*")) { + const wildcardMatches = findWildcardMatches(text, criteria); + if (wildcardMatches.length > 0) return wildcardMatches; + } + if (criteria.includes(" ") || criteria.length < this.config.minFuzzyLength) { + const literalMatches = findLiteralMatches(text, criteria); + if (literalMatches.length > 0) return literalMatches; + } + if (this.config.preferFuzzy || criteria.length >= this.config.minFuzzyLength) { + const fuzzyMatches = findFuzzyMatches(text, criteria); + if (fuzzyMatches.length > 0) return fuzzyMatches; + } + return findLiteralMatches(text, criteria); + } + getConfig() { + return { ...this.config }; + } + } + const DefaultHybridSearchStrategy = new HybridSearchStrategy(); + class StrategyFactory { + static create(config) { + if (typeof config === "string") { + config = { type: config }; + } + switch (config.type) { + case "literal": + return LiteralSearchStrategy; + case "fuzzy": + return FuzzySearchStrategy; + case "wildcard": + return WildcardSearchStrategy; + case "hybrid": + return new HybridSearchStrategy(config.hybridConfig); + default: + return LiteralSearchStrategy; + } + } + static getAvailableStrategies() { + return ["literal", "fuzzy", "wildcard", "hybrid"]; + } + static isValidStrategy(type) { + return this.getAvailableStrategies().includes(type); } } - 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); - }); function merge(target, source) { return { ...target, ...source }; } @@ -238,12 +446,21 @@ 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; + if (this.options.searchStrategy.findMatches) { + 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) { @@ -254,6 +471,9 @@ return false; } searchStrategy(strategy) { + if (StrategyFactory.isValidStrategy(strategy)) { + return StrategyFactory.create(strategy); + } switch (strategy) { case "fuzzy": return FuzzySearchStrategy; @@ -282,8 +502,22 @@ options.middleware = _options.middleware; } } - function compile(data) { + function compile(data, query) { return options.template.replace(options.pattern, function(match, prop) { + var _a; + const matchInfo = (_a = data._matchInfo) == null ? void 0 : _a[prop]; + if (matchInfo && matchInfo.length > 0 && query) { + const value2 = options.middleware(prop, data[prop], options.template, query, matchInfo); + if (typeof value2 !== "undefined") { + return value2; + } + } + if (query) { + const value2 = options.middleware(prop, data[prop], options.template, query); + if (typeof value2 !== "undefined") { + return value2; + } + } const value = options.middleware(prop, data[prop], options.template); if (typeof value !== "undefined") { return value; @@ -357,7 +591,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 +626,117 @@ return rv; } }; + function escapeHtml(text) { + const map = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + function mergeOverlappingMatches(matches) { + if (matches.length === 0) return []; + const sorted = [...matches].sort((a, b) => a.start - b.start); + const merged = [{ ...sorted[0] }]; + for (let i = 1; i < sorted.length; i++) { + const current = sorted[i]; + const last = merged[merged.length - 1]; + if (current.start <= last.end) { + last.end = Math.max(last.end, current.end); + } else { + merged.push({ ...current }); + } + } + return merged; + } + function highlightWithMatchInfo(text, matchInfo, options2 = {}) { + if (!text || matchInfo.length === 0) { + return escapeHtml(text); + } + const className = options2.className || "search-highlight"; + const maxLength = options2.maxLength; + const mergedMatches = mergeOverlappingMatches(matchInfo); + let result = ""; + let lastIndex = 0; + for (const match of mergedMatches) { + result += escapeHtml(text.substring(lastIndex, match.start)); + result += `${escapeHtml(text.substring(match.start, match.end))}`; + lastIndex = match.end; + } + result += escapeHtml(text.substring(lastIndex)); + if (maxLength && result.length > maxLength) { + result = truncateAroundMatches(text, mergedMatches, maxLength, options2.contextLength || 30, className); + } + return result; + } + function truncateAroundMatches(text, matches, maxLength, contextLength, className) { + if (matches.length === 0) { + const truncated = text.substring(0, maxLength - 3); + return escapeHtml(truncated) + "..."; + } + const firstMatch = matches[0]; + const start = Math.max(0, firstMatch.start - contextLength); + const end = Math.min(text.length, firstMatch.end + contextLength); + let result = ""; + if (start > 0) { + result += "..."; + } + const snippet = text.substring(start, end); + const adjustedMatches = matches.filter((m) => m.start < end && m.end > start).map((m) => ({ + ...m, + start: Math.max(0, m.start - start), + end: Math.min(snippet.length, m.end - start) + })); + let lastIndex = 0; + for (const match of adjustedMatches) { + result += escapeHtml(snippet.substring(lastIndex, match.start)); + result += `${escapeHtml(snippet.substring(match.start, match.end))}`; + lastIndex = match.end; + } + result += escapeHtml(snippet.substring(lastIndex)); + if (end < text.length) { + result += "..."; + } + return result; + } + function createHighlightTemplateMiddleware(options2 = {}) { + const highlightOptions = { + className: options2.className || "search-highlight", + maxLength: options2.maxLength, + contextLength: options2.contextLength || 30 + }; + return function(prop, value, _template, query, matchInfo) { + if ((prop === "content" || prop === "desc" || prop === "description") && query && typeof value === "string") { + if (matchInfo && matchInfo.length > 0) { + const highlighted = highlightWithMatchInfo(value, matchInfo, highlightOptions); + return highlighted !== value ? highlighted : void 0; + } + } + return void 0; + }; + } + function defaultHighlightMiddleware(prop, value, template, query, matchInfo) { + const middleware = createHighlightTemplateMiddleware(); + return middleware(prop, value, template, query, matchInfo); + } function SimpleJekyllSearch(options2) { const instance = new SimpleJekyllSearch$1(); return instance.init(options2); } if (typeof window !== "undefined") { window.SimpleJekyllSearch = SimpleJekyllSearch; + window.createHighlightTemplateMiddleware = createHighlightTemplateMiddleware; } + exports2.DefaultHybridSearchStrategy = DefaultHybridSearchStrategy; + exports2.HybridSearchStrategy = HybridSearchStrategy; + exports2.StrategyFactory = StrategyFactory; + exports2.createHighlightTemplateMiddleware = createHighlightTemplateMiddleware; exports2.default = SimpleJekyllSearch; + exports2.defaultHighlightMiddleware = defaultHighlightMiddleware; + exports2.escapeHtml = escapeHtml; + exports2.highlightWithMatchInfo = highlightWithMatchInfo; + exports2.mergeOverlappingMatches = mergeOverlappingMatches; Object.defineProperties(exports2, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } }); }); diff --git a/docs/assets/js/simple-jekyll-search.min.js b/docs/assets/js/simple-jekyll-search.min.js index 0d7044f..7facd22 100644 --- a/docs/assets/js/simple-jekyll-search.min.js +++ b/docs/assets/js/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 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 wildcardSearch(text,pattern){const regexPattern=pattern.replace(/\*/g,".*");const regex=new RegExp(`^${regexPattern}$`,"i");return regex.test(text)}function findLiteralMatches(text,criteria){const matches=[];const lowerText=text.toLowerCase();const lowerCriteria=criteria.toLowerCase();let startIndex=0;while((startIndex=lowerText.indexOf(lowerCriteria,startIndex))!==-1){matches.push({start:startIndex,end:startIndex+criteria.length,text:text.substring(startIndex,startIndex+criteria.length),type:"exact"});startIndex+=criteria.length}return matches}function findFuzzyMatches(text,criteria){criteria=criteria.trimEnd();if(criteria.length===0)return[];const lowerText=text.toLowerCase();const lowerCriteria=criteria.toLowerCase();let textIndex=0;let criteriaIndex=0;const matchedIndices=[];while(textIndexfuzzySearch(text,criteria)||literalSearch(text,criteria)),((text,criteria)=>{const fuzzyMatches=findFuzzyMatches(text,criteria);if(fuzzyMatches.length>0){return fuzzyMatches}return findLiteralMatches(text,criteria)}));const WildcardSearchStrategy=new SearchStrategy(((text,criteria)=>wildcardSearch(text,criteria)||literalSearch(text,criteria)),((text,criteria)=>{const wildcardMatches=findWildcardMatches(text,criteria);if(wildcardMatches.length>0){return wildcardMatches}return findLiteralMatches(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;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){var _a;const matchInfo=(_a=data._matchInfo)==null?void 0:_a[prop];if(matchInfo&&matchInfo.length>0&&query){const value2=options.middleware(prop,data[prop],options.template,query,matchInfo);if(typeof value2!=="undefined"){return value2}}if(query){const value2=options.middleware(prop,data[prop],options.template,query);if(typeof value2!=="undefined"){return value2}}const value=options.middleware(prop,data[prop],options.template);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 escapeHtml(text){const map={"&":"&","<":"<",">":">",'"':""","'":"'"};return text.replace(/[&<>"']/g,(m=>map[m]))}function mergeOverlappingMatches(matches){if(matches.length===0)return[];const sorted=[...matches].sort(((a,b)=>a.start-b.start));const merged=[{...sorted[0]}];for(let i=1;i${escapeHtml(text.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(text.substring(lastIndex));if(maxLength&&result.length>maxLength){result=truncateAroundMatches(text,mergedMatches,maxLength,options2.contextLength||30,className)}return result}function truncateAroundMatches(text,matches,maxLength,contextLength,className){if(matches.length===0){const truncated=text.substring(0,maxLength-3);return escapeHtml(truncated)+"..."}const firstMatch=matches[0];const start=Math.max(0,firstMatch.start-contextLength);const end=Math.min(text.length,firstMatch.end+contextLength);let result="";if(start>0){result+="..."}const snippet=text.substring(start,end);const adjustedMatches=matches.filter((m=>m.startstart)).map((m=>({...m,start:Math.max(0,m.start-start),end:Math.min(snippet.length,m.end-start)})));let lastIndex=0;for(const match of adjustedMatches){result+=escapeHtml(snippet.substring(lastIndex,match.start));result+=`${escapeHtml(snippet.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(snippet.substring(lastIndex));if(end0){const highlighted=highlightWithMatchInfo(value,matchInfo,highlightOptions);return highlighted!==value?highlighted:void 0}}return void 0}}function defaultHighlightMiddleware(prop,value,template,query,matchInfo){const middleware=createHighlightTemplateMiddleware();return middleware(prop,value,template,query,matchInfo)}function SimpleJekyllSearch(options2){const instance=new SimpleJekyllSearch$1;return instance.init(options2)}if(typeof window!=="undefined"){window.SimpleJekyllSearch=SimpleJekyllSearch;window.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware}exports2.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware;exports2.default=SimpleJekyllSearch;exports2.defaultHighlightMiddleware=defaultHighlightMiddleware;exports2.escapeHtml=escapeHtml;exports2.highlightWithMatchInfo=highlightWithMatchInfo;exports2.mergeOverlappingMatches=mergeOverlappingMatches;Object.defineProperties(exports2,{__esModule:{value:true},[Symbol.toStringTag]:{value:"Module"}})})); \ No newline at end of file diff --git a/src/Repository.ts b/src/Repository.ts index 50e1853..09b53e4 100644 --- a/src/Repository.ts +++ b/src/Repository.ts @@ -7,7 +7,7 @@ import { RepositoryData, RepositoryOptions } from './utils/types'; export class Repository { private data: RepositoryData[] = []; - private options!: Required; + private options!: Required> & Pick; constructor(initialOptions: RepositoryOptions = {}) { this.setOptions(initialOptions); @@ -36,13 +36,19 @@ export class Repository { } public setOptions(newOptions: RepositoryOptions): void { + // Backward compatibility: convert fuzzy: true to strategy: 'fuzzy' + let strategy = newOptions?.strategy || DEFAULT_OPTIONS.strategy; + if (newOptions?.fuzzy && !newOptions?.strategy) { + console.warn('[Simple Jekyll Search] Warning: fuzzy option is deprecated. Use strategy: "fuzzy" instead.'); + strategy = 'fuzzy'; + } + this.options = { - fuzzy: newOptions?.fuzzy || DEFAULT_OPTIONS.fuzzy, limit: newOptions?.limit || DEFAULT_OPTIONS.limit, - searchStrategy: this.searchStrategy(newOptions?.strategy || (newOptions.fuzzy && 'fuzzy') || DEFAULT_OPTIONS.strategy), + searchStrategy: this.searchStrategy(strategy), sortMiddleware: newOptions?.sortMiddleware || DEFAULT_OPTIONS.sortMiddleware, exclude: newOptions?.exclude || DEFAULT_OPTIONS.exclude, - strategy: newOptions?.strategy || DEFAULT_OPTIONS.strategy, + strategy: strategy, }; } diff --git a/src/SimpleJekyllSearch.ts b/src/SimpleJekyllSearch.ts index 2dd9400..56742f5 100644 --- a/src/SimpleJekyllSearch.ts +++ b/src/SimpleJekyllSearch.ts @@ -105,7 +105,6 @@ class SimpleJekyllSearch { }); this.repository.setOptions({ - fuzzy: this.options.fuzzy, limit: this.options.limit, sortMiddleware: this.options.sortMiddleware, strategy: this.options.strategy, diff --git a/src/utils/default.ts b/src/utils/default.ts index fcb4c0e..349d473 100644 --- a/src/utils/default.ts +++ b/src/utils/default.ts @@ -11,11 +11,11 @@ export const DEFAULT_OPTIONS: Required = { sortMiddleware: NoSort, noResultsText: 'No results found', limit: 10, - fuzzy: false, strategy: 'literal', debounceTime: null, exclude: [], - onSearch: () => {} + onSearch: () => {}, + fuzzy: false // Deprecated, use strategy: 'fuzzy' instead }; export const REQUIRED_OPTIONS = ['searchInput', 'resultsContainer', 'json']; diff --git a/tests/Repository.test.ts b/tests/Repository.test.ts index efa91f7..d696923 100644 --- a/tests/Repository.test.ts +++ b/tests/Repository.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Repository } from '../src/Repository'; import { SearchResult } from '../src/utils/types'; @@ -48,12 +48,19 @@ describe('Repository', () => { expect(results[0]._matchInfo).toBeDefined(); }); - it('[deprecated] finds a fuzzy string', () => { + it('[v1.x deprecated] fuzzy option still works via backward compatibility', () => { + // Test backward compatibility: fuzzy: true should work and show warning + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + repository.setOptions({ fuzzy: true }); const results = repository.search('lrm ism'); + expect(results).toHaveLength(1); expect(results[0]).toMatchObject(loremElement); expect(results[0]._matchInfo).toBeDefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith('[Simple Jekyll Search] Warning: fuzzy option is deprecated. Use strategy: "fuzzy" instead.'); + + consoleWarnSpy.mockRestore(); }); it('finds a fuzzy string', () => { From 56ce562d5281391b445ed76f5868540bbbc19429 Mon Sep 17 00:00:00 2001 From: sylhare Date: Fri, 17 Oct 2025 09:44:21 -0400 Subject: [PATCH 08/18] Fix jekyll startup issues # Conflicts: # package.json --- docs/_config.yml | 1 + docs/_plugins/simple_search_filter.rb | 3 ++ docs/_plugins/simple_search_filter_cn.rb | 3 ++ scripts/start-jekyll.js | 53 ++++++++++++++++-------- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index 7c90363..7eb09e0 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -7,6 +7,7 @@ url: "https://sylhare.github.io" # Build settings markdown: kramdown +encoding: utf-8 sass: style: compressed diff --git a/docs/_plugins/simple_search_filter.rb b/docs/_plugins/simple_search_filter.rb index f046557..7def549 100644 --- a/docs/_plugins/simple_search_filter.rb +++ b/docs/_plugins/simple_search_filter.rb @@ -1,3 +1,6 @@ +# encoding: utf-8 +# frozen_string_literal: true + # Example usage in a Jekyll template: # {{ some_variable | remove_chars }} # diff --git a/docs/_plugins/simple_search_filter_cn.rb b/docs/_plugins/simple_search_filter_cn.rb index 264daac..6f91bf8 100644 --- a/docs/_plugins/simple_search_filter_cn.rb +++ b/docs/_plugins/simple_search_filter_cn.rb @@ -1,3 +1,6 @@ +# encoding: utf-8 +# frozen_string_literal: true + # Same as `simple_search_filter.rb`, but with additional adapted for Chinese characters # Example usage in a Jekyll template: # {{ some_variable | remove_chars_cn }} diff --git a/scripts/start-jekyll.js b/scripts/start-jekyll.js index 5fc7015..d6d8ccc 100644 --- a/scripts/start-jekyll.js +++ b/scripts/start-jekyll.js @@ -1,23 +1,40 @@ -import { spawn } from 'child_process'; +import { spawn, exec } from 'child_process'; -console.log('Starting Jekyll server in detached mode...'); +console.log('Checking for processes on port 4000...'); -const jekyllProcess = spawn('bundle', ['exec', 'jekyll', 'serve', '--detach'], { - cwd: 'docs', - stdio: 'inherit', // Ensures output is displayed in the terminal - shell: true, // Allows running shell commands -}); - -jekyllProcess.on('error', (error) => { - console.error('Error starting Jekyll server:', error.message); - process.exit(1); -}); - -jekyllProcess.on('close', (code) => { - if (code === 0) { - console.log('Jekyll server started successfully!'); +// Kill any process using port 4000 +exec('lsof -ti:4000 | xargs kill -9 2>/dev/null || true', (error, stdout, stderr) => { + if (stdout) { + console.log('Killed process on port 4000'); } else { - console.error(`Jekyll server exited with code ${code}`); + console.log('No process found on port 4000'); } - process.exit(code); + + console.log('Starting Jekyll server in detached mode...'); + + const jekyllProcess = spawn('bundle', ['exec', 'jekyll', 'serve', '--detach'], { + cwd: 'docs', + stdio: 'inherit', + shell: true, + env: { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + LC_CTYPE: 'en_US.UTF-8' + } + }); + + jekyllProcess.on('error', (error) => { + console.error('Error starting Jekyll server:', error.message); + process.exit(1); + }); + + jekyllProcess.on('close', (code) => { + if (code === 0) { + console.log('Jekyll server started successfully!'); + } else { + console.error(`Jekyll server exited with code ${code}`); + } + process.exit(code); + }); }); \ No newline at end of file From 6e3c53877b33ab58dfd944fdd60a6bf975acf2bc Mon Sep 17 00:00:00 2001 From: sylhare Date: Fri, 17 Oct 2025 09:44:46 -0400 Subject: [PATCH 09/18] Add custom e2e highlight tests --- cypress/e2e/simple-jekyll-search.cy.ts | 41 ++++++++++++++++++++++++++ docs/_includes/search.html | 8 ++++- docs/_sass/_custom.scss | 12 ++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/simple-jekyll-search.cy.ts b/cypress/e2e/simple-jekyll-search.cy.ts index 94d17f2..d2fefa3 100644 --- a/cypress/e2e/simple-jekyll-search.cy.ts +++ b/cypress/e2e/simple-jekyll-search.cy.ts @@ -60,4 +60,45 @@ describe('Simple Jekyll Search', () => { .should('exist'); }); }); + + describe('Highlighting Middleware', () => { + it('should highlight search terms in results', () => { + cy.get('#search-input') + .type('Lorem'); + + cy.get('#results-container') + .should('be.visible'); + + cy.get('#results-container .search-desc .search-highlight') + .should('exist') + .should('have.css', 'background-color', 'rgb(255, 255, 0)'); + + cy.get('#results-container .search-desc .search-highlight') + .first() + .should('contain', 'Lorem'); + }); + + it('should highlight multiple occurrences', () => { + cy.get('#search-input') + .type('test'); + + cy.get('#results-container') + .should('be.visible'); + + cy.get('#results-container .search-desc .search-highlight') + .should('have.length.at.least', 1); + }); + + it('should escape HTML in search results', () => { + cy.get('#search-input') + .type('sed'); + + cy.get('#results-container') + .should('be.visible'); + + cy.get('#results-container .search-desc') + .should('exist') + .and('not.contain', ' + From e35dd2fcd587d267b0fa9a31912fafee2aed4435 Mon Sep 17 00:00:00 2001 From: sylhare Date: Sun, 19 Oct 2025 11:08:41 -0400 Subject: [PATCH 14/18] Improve search performance --- dest/simple-jekyll-search.js | 37 ++++++++------------------------- src/Repository.ts | 19 +++++++++-------- src/utils.ts | 7 +------ tests/utils.test.ts | 40 +++++++++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 44 deletions(-) diff --git a/dest/simple-jekyll-search.js b/dest/simple-jekyll-search.js index fcde87d..f286048 100644 --- a/dest/simple-jekyll-search.js +++ b/dest/simple-jekyll-search.js @@ -218,11 +218,7 @@ return { ...target, ...source }; } function isJSON(json) { - try { - return !!(json instanceof Object && JSON.parse(JSON.stringify(json))); - } catch (_err) { - return false; - } + return Array.isArray(json) || json !== null && typeof json === "object"; } function NoSort() { return 0; @@ -230,21 +226,6 @@ 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, @@ -278,6 +259,7 @@ class Repository { constructor(initialOptions = {}) { this.data = []; + this.excludePatterns = []; this.setOptions(initialOptions); } put(input) { @@ -297,7 +279,8 @@ if (!criteria) { return []; } - return clone(this.findMatches(this.data, criteria).sort(this.options.sortMiddleware)); + const matches = this.findMatches(this.data, criteria).sort(this.options.sortMiddleware); + return matches.map((item) => ({ ...item })); } setOptions(newOptions) { let strategy = (newOptions == null ? void 0 : newOptions.strategy) || DEFAULT_OPTIONS.strategy; @@ -305,11 +288,13 @@ console.warn('[Simple Jekyll Search] Warning: fuzzy option is deprecated. Use strategy: "fuzzy" instead.'); strategy = "fuzzy"; } + const exclude = (newOptions == null ? void 0 : newOptions.exclude) || DEFAULT_OPTIONS.exclude; + this.excludePatterns = exclude.map((pattern) => new RegExp(pattern)); this.options = { limit: (newOptions == null ? void 0 : newOptions.limit) || DEFAULT_OPTIONS.limit, searchStrategy: this.searchStrategy(strategy), sortMiddleware: (newOptions == null ? void 0 : newOptions.sortMiddleware) || DEFAULT_OPTIONS.sortMiddleware, - exclude: (newOptions == null ? void 0 : newOptions.exclude) || DEFAULT_OPTIONS.exclude, + exclude, strategy }; } @@ -355,12 +340,8 @@ 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; + const termStr = String(term); + return this.excludePatterns.some((regex) => regex.test(termStr)); } searchStrategy(strategy) { if (StrategyFactory.isValidStrategy(strategy)) { diff --git a/src/Repository.ts b/src/Repository.ts index 09b53e4..3e9cf04 100644 --- a/src/Repository.ts +++ b/src/Repository.ts @@ -1,13 +1,14 @@ import { FuzzySearchStrategy, LiteralSearchStrategy, WildcardSearchStrategy } from './SearchStrategies/SearchStrategy'; import { Matcher } from './SearchStrategies/types'; import { StrategyFactory, StrategyType } from './SearchStrategies/StrategyFactory'; -import { clone, isObject } from './utils'; +import { isObject } from './utils'; import { DEFAULT_OPTIONS } from './utils/default'; import { RepositoryData, RepositoryOptions } from './utils/types'; export class Repository { private data: RepositoryData[] = []; private options!: Required> & Pick; + private excludePatterns: RegExp[] = []; constructor(initialOptions: RepositoryOptions = {}) { this.setOptions(initialOptions); @@ -32,7 +33,8 @@ export class Repository { if (!criteria) { return []; } - return clone(this.findMatches(this.data, criteria).sort(this.options.sortMiddleware)); + const matches = this.findMatches(this.data, criteria).sort(this.options.sortMiddleware); + return matches.map(item => ({ ...item })); } public setOptions(newOptions: RepositoryOptions): void { @@ -43,11 +45,14 @@ export class Repository { strategy = 'fuzzy'; } + const exclude = newOptions?.exclude || DEFAULT_OPTIONS.exclude; + this.excludePatterns = exclude.map(pattern => new RegExp(pattern)); + this.options = { limit: newOptions?.limit || DEFAULT_OPTIONS.limit, searchStrategy: this.searchStrategy(strategy), sortMiddleware: newOptions?.sortMiddleware || DEFAULT_OPTIONS.sortMiddleware, - exclude: newOptions?.exclude || DEFAULT_OPTIONS.exclude, + exclude: exclude, strategy: strategy, }; } @@ -101,12 +106,8 @@ export class Repository { } private isExcluded(term: any): boolean { - for (const excludedTerm of this.options.exclude) { - if (new RegExp(excludedTerm).test(String(term))) { - return true; - } - } - return false; + const termStr = String(term); + return this.excludePatterns.some(regex => regex.test(termStr)); } private searchStrategy( diff --git a/src/utils.ts b/src/utils.ts index 601bcaa..7afc65a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,12 +5,7 @@ export function merge(target: T, source: Partial): T { } export function isJSON(json: any): boolean { - try { - return !!(json instanceof Object && JSON.parse(JSON.stringify(json))); - - } catch (_err) { - return false; - } + return Array.isArray(json) || (json !== null && typeof json === 'object'); } export function NoSort(): number { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index d894a3f..d5fb2f7 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -51,8 +51,46 @@ describe('utils', () => { }); describe('isJSON', () => { - it('returns true if is JSON object', () => { + it('returns true for plain objects', () => { expect(isJSON({ foo: 'bar' })).toBe(true); + expect(isJSON({})).toBe(true); + expect(isJSON({ nested: { key: 'value' } })).toBe(true); + }); + + it('returns true for arrays', () => { + expect(isJSON([])).toBe(true); + expect(isJSON([1, 2, 3])).toBe(true); + expect(isJSON([{ foo: 'bar' }])).toBe(true); + }); + + it('returns false for null', () => { + expect(isJSON(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isJSON(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isJSON(42)).toBe(false); + expect(isJSON(0)).toBe(false); + expect(isJSON('string')).toBe(false); + expect(isJSON('')).toBe(false); + expect(isJSON(true)).toBe(false); + expect(isJSON(false)).toBe(false); + }); + + it('returns true for Date objects', () => { + expect(isJSON(new Date())).toBe(true); + }); + + it('returns true for RegExp objects', () => { + expect(isJSON(/regex/)).toBe(true); + }); + + it('returns false for functions', () => { + expect(isJSON(() => {})).toBe(false); + expect(isJSON(function() {})).toBe(false); }); }); From 8b25092058c475ab3b6827a5fe009c2b60cd3012 Mon Sep 17 00:00:00 2001 From: sylhare Date: Tue, 21 Oct 2025 10:11:30 -0400 Subject: [PATCH 15/18] Add on error catch --- dest/simple-jekyll-search.js | 33 ++++++++--- src/SimpleJekyllSearch.ts | 31 ++++++++--- src/utils/default.ts | 1 + src/utils/types.ts | 1 + tests/SimpleJekyllSearch.test.ts | 96 +++++++++++++++++++++++++++++++- 5 files changed, 145 insertions(+), 17 deletions(-) diff --git a/dest/simple-jekyll-search.js b/dest/simple-jekyll-search.js index f286048..a83e997 100644 --- a/dest/simple-jekyll-search.js +++ b/dest/simple-jekyll-search.js @@ -242,6 +242,7 @@ exclude: [], onSearch: () => { }, + onError: (error) => console.error("SimpleJekyllSearch error:", error), fuzzy: false // Deprecated, use strategy: 'fuzzy' instead }; @@ -401,6 +402,9 @@ let SimpleJekyllSearch$1 = class SimpleJekyllSearch { constructor() { this.debounceTimerHandle = null; + this.eventHandler = null; + this.pendingRequest = null; + this.isInitialized = false; this.options = { ...DEFAULT_OPTIONS }; this.repository = new Repository(); this.optionsValidator = new OptionsValidator({ @@ -436,15 +440,28 @@ }); } 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); + this.eventHandler = (e) => { + var _a, _b; + try { + const inputEvent = e; + if (!WHITELISTED_KEYS.has(inputEvent.key)) { + this.emptyResultsContainer(); + this.debounce(() => { + var _a2, _b2; + try { + this.search(e.target.value); + } catch (searchError) { + console.error("Search error:", searchError); + (_b2 = (_a2 = this.options).onError) == null ? void 0 : _b2.call(_a2, searchError); + } + }, this.options.debounceTime ?? null); + } + } catch (error) { + console.error("Input handler error:", error); + (_b = (_a = this.options).onError) == null ? void 0 : _b.call(_a, error); } - }); + }; + this.options.searchInput.addEventListener("input", this.eventHandler); } search(query) { var _a, _b; diff --git a/src/SimpleJekyllSearch.ts b/src/SimpleJekyllSearch.ts index 56742f5..c845992 100644 --- a/src/SimpleJekyllSearch.ts +++ b/src/SimpleJekyllSearch.ts @@ -11,6 +11,9 @@ class SimpleJekyllSearch { private repository: Repository; private optionsValidator: OptionsValidator; private debounceTimerHandle: NodeJS.Timeout | null = null; + private eventHandler: ((e: Event) => void) | null = null; + private pendingRequest: XMLHttpRequest | null = null; + private isInitialized: boolean = false; constructor() { this.options = { ...DEFAULT_OPTIONS }; @@ -54,15 +57,27 @@ class SimpleJekyllSearch { } private registerInput(): void { - this.options.searchInput.addEventListener('input', (e: Event) => { - const inputEvent = e as KeyboardEvent; - if (!WHITELISTED_KEYS.has(inputEvent.key)) { - this.emptyResultsContainer(); - this.debounce(() => { - this.search((e.target as HTMLInputElement).value); - }, this.options.debounceTime ?? null); + this.eventHandler = (e: Event) => { + try { + const inputEvent = e as KeyboardEvent; + if (!WHITELISTED_KEYS.has(inputEvent.key)) { + this.emptyResultsContainer(); + this.debounce(() => { + try { + this.search((e.target as HTMLInputElement).value); + } catch (searchError) { + console.error('Search error:', searchError); + this.options.onError?.(searchError as Error); + } + }, this.options.debounceTime ?? null); + } + } catch (error) { + console.error('Input handler error:', error); + this.options.onError?.(error as Error); } - }); + }; + + this.options.searchInput.addEventListener('input', this.eventHandler); } public search(query: string): void { diff --git a/src/utils/default.ts b/src/utils/default.ts index 349d473..0c169e6 100644 --- a/src/utils/default.ts +++ b/src/utils/default.ts @@ -15,6 +15,7 @@ export const DEFAULT_OPTIONS: Required = { debounceTime: null, exclude: [], onSearch: () => {}, + onError: (error: Error) => console.error('SimpleJekyllSearch error:', error), fuzzy: false // Deprecated, use strategy: 'fuzzy' instead }; diff --git a/src/utils/types.ts b/src/utils/types.ts index cc93a52..b5133e6 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -46,6 +46,7 @@ export interface SearchOptions extends Omit noResultsText?: string; debounceTime?: number | null; onSearch?: () => void; + onError?: (error: Error) => void; } export interface SimpleJekyllSearchInstance { diff --git a/tests/SimpleJekyllSearch.test.ts b/tests/SimpleJekyllSearch.test.ts index daf236c..f685792 100644 --- a/tests/SimpleJekyllSearch.test.ts +++ b/tests/SimpleJekyllSearch.test.ts @@ -1,5 +1,5 @@ import { JSDOM } from 'jsdom'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import SimpleJekyllSearch from '../src/SimpleJekyllSearch'; import { SearchData, SearchOptions } from '../src/utils/types'; @@ -144,4 +144,98 @@ describe('SimpleJekyllSearch', () => { expect(resultsContainer.innerHTML).toContain('Test Post'); }); }); + + describe('error handling', () => { + beforeEach(() => { + mockOptions.json = mockSearchData; + }); + + it('should call onError callback when provided', async () => { + const onErrorSpy = vi.fn(); + const optionsWithErrorHandler = { ...mockOptions, onError: onErrorSpy }; + + searchInstance.init(optionsWithErrorHandler); + + const input = mockOptions.searchInput; + input.value = 'test'; + input.dispatchEvent(new dom.window.KeyboardEvent('input', { key: 't' })); + + await new Promise(resolve => setTimeout(resolve, mockOptions.debounceTime! + 10)); + + expect(onErrorSpy).not.toHaveBeenCalled(); + }); + + it('should handle malformed search data gracefully', async () => { + const onErrorSpy = vi.fn(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const malformedData = [ + { title: 'Valid Post', url: '/valid' }, + { title: null, url: undefined }, + { title: 'Another Valid Post', url: '/another' } + ]; + + const optionsWithMalformedData = { + ...mockOptions, + json: malformedData as any, + onError: onErrorSpy + }; + + expect(() => searchInstance.init(optionsWithMalformedData)).not.toThrow(); + + const input = mockOptions.searchInput; + input.value = 'Valid'; + input.dispatchEvent(new dom.window.KeyboardEvent('input', { key: 'V' })); + + await new Promise(resolve => setTimeout(resolve, mockOptions.debounceTime! + 10)); + + expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error)); + consoleErrorSpy.mockRestore(); + }); + + it('should handle missing DOM elements gracefully', async () => { + const onErrorSpy = vi.fn(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const optionsWithMissingElement = { + ...mockOptions, + searchInput: null as any, + onError: onErrorSpy + }; + + expect(() => searchInstance.init(optionsWithMissingElement)).toThrow(); + consoleErrorSpy.mockRestore(); + }); + + it('should use default error handler when onError not provided', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + searchInstance.init(mockOptions); + + const input = mockOptions.searchInput; + input.value = 'test'; + input.dispatchEvent(new dom.window.KeyboardEvent('input', { key: 't' })); + + await new Promise(resolve => setTimeout(resolve, mockOptions.debounceTime! + 10)); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('should handle invalid search queries gracefully', async () => { + const onErrorSpy = vi.fn(); + const optionsWithErrorHandler = { ...mockOptions, onError: onErrorSpy }; + + searchInstance.init(optionsWithErrorHandler); + + const input = mockOptions.searchInput; + input.value = 'a'.repeat(10000); + input.dispatchEvent(new dom.window.KeyboardEvent('input', { key: 'a' })); + + await new Promise(resolve => setTimeout(resolve, mockOptions.debounceTime! + 10)); + + expect(mockOptions.resultsContainer.innerHTML).toContain('No results found'); + expect(onErrorSpy).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file From 40a4dacec9791607588f7329dc1d2ab7edf14032 Mon Sep 17 00:00:00 2001 From: sylhare Date: Tue, 21 Oct 2025 11:08:42 -0400 Subject: [PATCH 16/18] Update build to last version --- dest/simple-jekyll-search.min.js | 4 ++-- docs/assets/js/simple-jekyll-search.min.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dest/simple-jekyll-search.min.js b/dest/simple-jekyll-search.min.js index 0d7044f..218f3ad 100644 --- a/dest/simple-jekyll-search.min.js +++ b/dest/simple-jekyll-search.min.js @@ -1,7 +1,7 @@ /*! - * Simple-Jekyll-Search v1.15.2 + * Simple-Jekyll-Search v2.0.0 * Copyright 2015-2022, Christian Fei * 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 findLiteralMatches(text,criteria){const lowerText=text.trim().toLowerCase();const pattern=criteria.endsWith(" ")?[criteria.toLowerCase()]:criteria.trim().toLowerCase().split(" ");const wordsFound=pattern.filter((word=>lowerText.indexOf(word)>=0)).length;if(wordsFound!==pattern.length){return[]}const matches=[];for(const word of pattern){if(!word||word.length===0)continue;let startIndex=0;while((startIndex=lowerText.indexOf(word,startIndex))!==-1){matches.push({start:startIndex,end:startIndex+word.length,text:text.substring(startIndex,startIndex+word.length),type:"exact"});startIndex+=word.length}}return matches}function findFuzzyMatches(text,criteria){criteria=criteria.trimEnd();if(criteria.length===0)return[];const lowerText=text.toLowerCase();const lowerCriteria=criteria.toLowerCase();let textIndex=0;let criteriaIndex=0;const matchedIndices=[];while(textIndex0}findMatches(text,criteria){if(text===null||text.trim()===""||!criteria){return[]}return this.findMatchesFunction(text,criteria)}}const LiteralSearchStrategy=new SearchStrategy(findLiteralMatches);const FuzzySearchStrategy=new SearchStrategy(((text,criteria)=>{const fuzzyMatches=findFuzzyMatches(text,criteria);if(fuzzyMatches.length>0){return fuzzyMatches}return findLiteralMatches(text,criteria)}));const WildcardSearchStrategy=new SearchStrategy(((text,criteria)=>{const wildcardMatches=findWildcardMatches(text,criteria);if(wildcardMatches.length>0){return wildcardMatches}return findLiteralMatches(text,criteria)}));class HybridSearchStrategy extends SearchStrategy{constructor(config={}){super(((text,criteria)=>this.hybridFind(text,criteria)));this.config={preferFuzzy:config.preferFuzzy??false,wildcardPriority:config.wildcardPriority??true,minFuzzyLength:config.minFuzzyLength??3}}hybridFind(text,criteria){if(this.config.wildcardPriority&&criteria.includes("*")){const wildcardMatches=findWildcardMatches(text,criteria);if(wildcardMatches.length>0)return wildcardMatches}if(criteria.includes(" ")||criteria.length0)return literalMatches}if(this.config.preferFuzzy||criteria.length>=this.config.minFuzzyLength){const fuzzyMatches=findFuzzyMatches(text,criteria);if(fuzzyMatches.length>0)return fuzzyMatches}return findLiteralMatches(text,criteria)}getConfig(){return{...this.config}}}const DefaultHybridSearchStrategy=new HybridSearchStrategy;class StrategyFactory{static create(config){if(typeof config==="string"){config={type:config}}switch(config.type){case"literal":return LiteralSearchStrategy;case"fuzzy":return FuzzySearchStrategy;case"wildcard":return WildcardSearchStrategy;case"hybrid":return new HybridSearchStrategy(config.hybridConfig);default:return LiteralSearchStrategy}}static getAvailableStrategies(){return["literal","fuzzy","wildcard","hybrid"]}static isValidStrategy(type){return this.getAvailableStrategies().includes(type)}}function merge(target,source){return{...target,...source}}function isJSON(json){return Array.isArray(json)||json!==null&&typeof json==="object"}function NoSort(){return 0}function isObject(obj){return Boolean(obj)&&Object.prototype.toString.call(obj)==="[object Object]"}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,strategy:"literal",debounceTime:null,exclude:[],onSearch:()=>{},onError:error=>console.error("SimpleJekyllSearch error:",error),fuzzy:false};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.excludePatterns=[];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[]}const matches=this.findMatches(this.data,criteria).sort(this.options.sortMiddleware);return matches.map((item=>({...item})))}setOptions(newOptions){let strategy=(newOptions==null?void 0:newOptions.strategy)||DEFAULT_OPTIONS.strategy;if((newOptions==null?void 0:newOptions.fuzzy)&&!(newOptions==null?void 0:newOptions.strategy)){console.warn('[Simple Jekyll Search] Warning: fuzzy option is deprecated. Use strategy: "fuzzy" instead.');strategy="fuzzy"}const exclude=(newOptions==null?void 0:newOptions.exclude)||DEFAULT_OPTIONS.exclude;this.excludePatterns=exclude.map((pattern=>new RegExp(pattern)));this.options={limit:(newOptions==null?void 0:newOptions.limit)||DEFAULT_OPTIONS.limit,searchStrategy:this.searchStrategy(strategy),sortMiddleware:(newOptions==null?void 0:newOptions.sortMiddleware)||DEFAULT_OPTIONS.sortMiddleware,exclude:exclude,strategy: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){const termStr=String(term);return this.excludePatterns.some((regex=>regex.test(termStr)))}searchStrategy(strategy){if(StrategyFactory.isValidStrategy(strategy)){return StrategyFactory.create(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){var _a;const matchInfo=(_a=data._matchInfo)==null?void 0:_a[prop];if(matchInfo&&matchInfo.length>0&&query){const value2=options.middleware(prop,data[prop],options.template,query,matchInfo);if(typeof value2!=="undefined"){return value2}}if(query){const value2=options.middleware(prop,data[prop],options.template,query);if(typeof value2!=="undefined"){return value2}}const value=options.middleware(prop,data[prop],options.template);if(typeof value!=="undefined"){return value}return data[prop]||match}))}let SimpleJekyllSearch$1=class SimpleJekyllSearch{constructor(){this.debounceTimerHandle=null;this.eventHandler=null;this.pendingRequest=null;this.isInitialized=false;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.eventHandler=e=>{var _a,_b;try{const inputEvent=e;if(!WHITELISTED_KEYS.has(inputEvent.key)){this.emptyResultsContainer();this.debounce((()=>{var _a2,_b2;try{this.search(e.target.value)}catch(searchError){console.error("Search error:",searchError);(_b2=(_a2=this.options).onError)==null?void 0:_b2.call(_a2,searchError)}}),this.options.debounceTime??null)}}catch(error){console.error("Input handler error:",error);(_b=(_a=this.options).onError)==null?void 0:_b.call(_a,error)}};this.options.searchInput.addEventListener("input",this.eventHandler)}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({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 escapeHtml(text){const map={"&":"&","<":"<",">":">",'"':""","'":"'"};return text.replace(/[&<>"']/g,(m=>map[m]))}function mergeOverlappingMatches(matches){if(matches.length===0)return[];const sorted=[...matches].sort(((a,b)=>a.start-b.start));const merged=[{...sorted[0]}];for(let i=1;i${escapeHtml(text.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(text.substring(lastIndex));if(maxLength&&result.length>maxLength){result=truncateAroundMatches(text,mergedMatches,maxLength,options2.contextLength||30,className)}return result}function truncateAroundMatches(text,matches,maxLength,contextLength,className){if(matches.length===0){const truncated=text.substring(0,maxLength-3);return escapeHtml(truncated)+"..."}const firstMatch=matches[0];const start=Math.max(0,firstMatch.start-contextLength);const end=Math.min(text.length,firstMatch.end+contextLength);let result="";if(start>0){result+="..."}const snippet=text.substring(start,end);const adjustedMatches=matches.filter((m=>m.startstart)).map((m=>({...m,start:Math.max(0,m.start-start),end:Math.min(snippet.length,m.end-start)})));let lastIndex=0;for(const match of adjustedMatches){result+=escapeHtml(snippet.substring(lastIndex,match.start));result+=`${escapeHtml(snippet.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(snippet.substring(lastIndex));if(end0){const highlighted=highlightWithMatchInfo(value,matchInfo,highlightOptions);return highlighted!==value?highlighted:void 0}}return void 0}}function defaultHighlightMiddleware(prop,value,template,query,matchInfo){const middleware=createHighlightTemplateMiddleware();return middleware(prop,value,template,query,matchInfo)}function SimpleJekyllSearch(options2){const instance=new SimpleJekyllSearch$1;return instance.init(options2)}if(typeof window!=="undefined"){window.SimpleJekyllSearch=SimpleJekyllSearch;window.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware}exports2.DefaultHybridSearchStrategy=DefaultHybridSearchStrategy;exports2.HybridSearchStrategy=HybridSearchStrategy;exports2.StrategyFactory=StrategyFactory;exports2.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware;exports2.default=SimpleJekyllSearch;exports2.defaultHighlightMiddleware=defaultHighlightMiddleware;exports2.escapeHtml=escapeHtml;exports2.highlightWithMatchInfo=highlightWithMatchInfo;exports2.mergeOverlappingMatches=mergeOverlappingMatches;Object.defineProperties(exports2,{__esModule:{value:true},[Symbol.toStringTag]:{value:"Module"}})})); \ No newline at end of file diff --git a/docs/assets/js/simple-jekyll-search.min.js b/docs/assets/js/simple-jekyll-search.min.js index 7facd22..218f3ad 100644 --- a/docs/assets/js/simple-jekyll-search.min.js +++ b/docs/assets/js/simple-jekyll-search.min.js @@ -1,7 +1,7 @@ /*! - * Simple-Jekyll-Search v1.15.2 + * Simple-Jekyll-Search v2.0.0 * Copyright 2015-2022, Christian Fei * 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 wildcardSearch(text,pattern){const regexPattern=pattern.replace(/\*/g,".*");const regex=new RegExp(`^${regexPattern}$`,"i");return regex.test(text)}function findLiteralMatches(text,criteria){const matches=[];const lowerText=text.toLowerCase();const lowerCriteria=criteria.toLowerCase();let startIndex=0;while((startIndex=lowerText.indexOf(lowerCriteria,startIndex))!==-1){matches.push({start:startIndex,end:startIndex+criteria.length,text:text.substring(startIndex,startIndex+criteria.length),type:"exact"});startIndex+=criteria.length}return matches}function findFuzzyMatches(text,criteria){criteria=criteria.trimEnd();if(criteria.length===0)return[];const lowerText=text.toLowerCase();const lowerCriteria=criteria.toLowerCase();let textIndex=0;let criteriaIndex=0;const matchedIndices=[];while(textIndexfuzzySearch(text,criteria)||literalSearch(text,criteria)),((text,criteria)=>{const fuzzyMatches=findFuzzyMatches(text,criteria);if(fuzzyMatches.length>0){return fuzzyMatches}return findLiteralMatches(text,criteria)}));const WildcardSearchStrategy=new SearchStrategy(((text,criteria)=>wildcardSearch(text,criteria)||literalSearch(text,criteria)),((text,criteria)=>{const wildcardMatches=findWildcardMatches(text,criteria);if(wildcardMatches.length>0){return wildcardMatches}return findLiteralMatches(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;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){var _a;const matchInfo=(_a=data._matchInfo)==null?void 0:_a[prop];if(matchInfo&&matchInfo.length>0&&query){const value2=options.middleware(prop,data[prop],options.template,query,matchInfo);if(typeof value2!=="undefined"){return value2}}if(query){const value2=options.middleware(prop,data[prop],options.template,query);if(typeof value2!=="undefined"){return value2}}const value=options.middleware(prop,data[prop],options.template);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 escapeHtml(text){const map={"&":"&","<":"<",">":">",'"':""","'":"'"};return text.replace(/[&<>"']/g,(m=>map[m]))}function mergeOverlappingMatches(matches){if(matches.length===0)return[];const sorted=[...matches].sort(((a,b)=>a.start-b.start));const merged=[{...sorted[0]}];for(let i=1;i${escapeHtml(text.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(text.substring(lastIndex));if(maxLength&&result.length>maxLength){result=truncateAroundMatches(text,mergedMatches,maxLength,options2.contextLength||30,className)}return result}function truncateAroundMatches(text,matches,maxLength,contextLength,className){if(matches.length===0){const truncated=text.substring(0,maxLength-3);return escapeHtml(truncated)+"..."}const firstMatch=matches[0];const start=Math.max(0,firstMatch.start-contextLength);const end=Math.min(text.length,firstMatch.end+contextLength);let result="";if(start>0){result+="..."}const snippet=text.substring(start,end);const adjustedMatches=matches.filter((m=>m.startstart)).map((m=>({...m,start:Math.max(0,m.start-start),end:Math.min(snippet.length,m.end-start)})));let lastIndex=0;for(const match of adjustedMatches){result+=escapeHtml(snippet.substring(lastIndex,match.start));result+=`${escapeHtml(snippet.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(snippet.substring(lastIndex));if(end0){const highlighted=highlightWithMatchInfo(value,matchInfo,highlightOptions);return highlighted!==value?highlighted:void 0}}return void 0}}function defaultHighlightMiddleware(prop,value,template,query,matchInfo){const middleware=createHighlightTemplateMiddleware();return middleware(prop,value,template,query,matchInfo)}function SimpleJekyllSearch(options2){const instance=new SimpleJekyllSearch$1;return instance.init(options2)}if(typeof window!=="undefined"){window.SimpleJekyllSearch=SimpleJekyllSearch;window.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware}exports2.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware;exports2.default=SimpleJekyllSearch;exports2.defaultHighlightMiddleware=defaultHighlightMiddleware;exports2.escapeHtml=escapeHtml;exports2.highlightWithMatchInfo=highlightWithMatchInfo;exports2.mergeOverlappingMatches=mergeOverlappingMatches;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 findLiteralMatches(text,criteria){const lowerText=text.trim().toLowerCase();const pattern=criteria.endsWith(" ")?[criteria.toLowerCase()]:criteria.trim().toLowerCase().split(" ");const wordsFound=pattern.filter((word=>lowerText.indexOf(word)>=0)).length;if(wordsFound!==pattern.length){return[]}const matches=[];for(const word of pattern){if(!word||word.length===0)continue;let startIndex=0;while((startIndex=lowerText.indexOf(word,startIndex))!==-1){matches.push({start:startIndex,end:startIndex+word.length,text:text.substring(startIndex,startIndex+word.length),type:"exact"});startIndex+=word.length}}return matches}function findFuzzyMatches(text,criteria){criteria=criteria.trimEnd();if(criteria.length===0)return[];const lowerText=text.toLowerCase();const lowerCriteria=criteria.toLowerCase();let textIndex=0;let criteriaIndex=0;const matchedIndices=[];while(textIndex0}findMatches(text,criteria){if(text===null||text.trim()===""||!criteria){return[]}return this.findMatchesFunction(text,criteria)}}const LiteralSearchStrategy=new SearchStrategy(findLiteralMatches);const FuzzySearchStrategy=new SearchStrategy(((text,criteria)=>{const fuzzyMatches=findFuzzyMatches(text,criteria);if(fuzzyMatches.length>0){return fuzzyMatches}return findLiteralMatches(text,criteria)}));const WildcardSearchStrategy=new SearchStrategy(((text,criteria)=>{const wildcardMatches=findWildcardMatches(text,criteria);if(wildcardMatches.length>0){return wildcardMatches}return findLiteralMatches(text,criteria)}));class HybridSearchStrategy extends SearchStrategy{constructor(config={}){super(((text,criteria)=>this.hybridFind(text,criteria)));this.config={preferFuzzy:config.preferFuzzy??false,wildcardPriority:config.wildcardPriority??true,minFuzzyLength:config.minFuzzyLength??3}}hybridFind(text,criteria){if(this.config.wildcardPriority&&criteria.includes("*")){const wildcardMatches=findWildcardMatches(text,criteria);if(wildcardMatches.length>0)return wildcardMatches}if(criteria.includes(" ")||criteria.length0)return literalMatches}if(this.config.preferFuzzy||criteria.length>=this.config.minFuzzyLength){const fuzzyMatches=findFuzzyMatches(text,criteria);if(fuzzyMatches.length>0)return fuzzyMatches}return findLiteralMatches(text,criteria)}getConfig(){return{...this.config}}}const DefaultHybridSearchStrategy=new HybridSearchStrategy;class StrategyFactory{static create(config){if(typeof config==="string"){config={type:config}}switch(config.type){case"literal":return LiteralSearchStrategy;case"fuzzy":return FuzzySearchStrategy;case"wildcard":return WildcardSearchStrategy;case"hybrid":return new HybridSearchStrategy(config.hybridConfig);default:return LiteralSearchStrategy}}static getAvailableStrategies(){return["literal","fuzzy","wildcard","hybrid"]}static isValidStrategy(type){return this.getAvailableStrategies().includes(type)}}function merge(target,source){return{...target,...source}}function isJSON(json){return Array.isArray(json)||json!==null&&typeof json==="object"}function NoSort(){return 0}function isObject(obj){return Boolean(obj)&&Object.prototype.toString.call(obj)==="[object Object]"}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,strategy:"literal",debounceTime:null,exclude:[],onSearch:()=>{},onError:error=>console.error("SimpleJekyllSearch error:",error),fuzzy:false};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.excludePatterns=[];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[]}const matches=this.findMatches(this.data,criteria).sort(this.options.sortMiddleware);return matches.map((item=>({...item})))}setOptions(newOptions){let strategy=(newOptions==null?void 0:newOptions.strategy)||DEFAULT_OPTIONS.strategy;if((newOptions==null?void 0:newOptions.fuzzy)&&!(newOptions==null?void 0:newOptions.strategy)){console.warn('[Simple Jekyll Search] Warning: fuzzy option is deprecated. Use strategy: "fuzzy" instead.');strategy="fuzzy"}const exclude=(newOptions==null?void 0:newOptions.exclude)||DEFAULT_OPTIONS.exclude;this.excludePatterns=exclude.map((pattern=>new RegExp(pattern)));this.options={limit:(newOptions==null?void 0:newOptions.limit)||DEFAULT_OPTIONS.limit,searchStrategy:this.searchStrategy(strategy),sortMiddleware:(newOptions==null?void 0:newOptions.sortMiddleware)||DEFAULT_OPTIONS.sortMiddleware,exclude:exclude,strategy: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){const termStr=String(term);return this.excludePatterns.some((regex=>regex.test(termStr)))}searchStrategy(strategy){if(StrategyFactory.isValidStrategy(strategy)){return StrategyFactory.create(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){var _a;const matchInfo=(_a=data._matchInfo)==null?void 0:_a[prop];if(matchInfo&&matchInfo.length>0&&query){const value2=options.middleware(prop,data[prop],options.template,query,matchInfo);if(typeof value2!=="undefined"){return value2}}if(query){const value2=options.middleware(prop,data[prop],options.template,query);if(typeof value2!=="undefined"){return value2}}const value=options.middleware(prop,data[prop],options.template);if(typeof value!=="undefined"){return value}return data[prop]||match}))}let SimpleJekyllSearch$1=class SimpleJekyllSearch{constructor(){this.debounceTimerHandle=null;this.eventHandler=null;this.pendingRequest=null;this.isInitialized=false;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.eventHandler=e=>{var _a,_b;try{const inputEvent=e;if(!WHITELISTED_KEYS.has(inputEvent.key)){this.emptyResultsContainer();this.debounce((()=>{var _a2,_b2;try{this.search(e.target.value)}catch(searchError){console.error("Search error:",searchError);(_b2=(_a2=this.options).onError)==null?void 0:_b2.call(_a2,searchError)}}),this.options.debounceTime??null)}}catch(error){console.error("Input handler error:",error);(_b=(_a=this.options).onError)==null?void 0:_b.call(_a,error)}};this.options.searchInput.addEventListener("input",this.eventHandler)}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({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 escapeHtml(text){const map={"&":"&","<":"<",">":">",'"':""","'":"'"};return text.replace(/[&<>"']/g,(m=>map[m]))}function mergeOverlappingMatches(matches){if(matches.length===0)return[];const sorted=[...matches].sort(((a,b)=>a.start-b.start));const merged=[{...sorted[0]}];for(let i=1;i${escapeHtml(text.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(text.substring(lastIndex));if(maxLength&&result.length>maxLength){result=truncateAroundMatches(text,mergedMatches,maxLength,options2.contextLength||30,className)}return result}function truncateAroundMatches(text,matches,maxLength,contextLength,className){if(matches.length===0){const truncated=text.substring(0,maxLength-3);return escapeHtml(truncated)+"..."}const firstMatch=matches[0];const start=Math.max(0,firstMatch.start-contextLength);const end=Math.min(text.length,firstMatch.end+contextLength);let result="";if(start>0){result+="..."}const snippet=text.substring(start,end);const adjustedMatches=matches.filter((m=>m.startstart)).map((m=>({...m,start:Math.max(0,m.start-start),end:Math.min(snippet.length,m.end-start)})));let lastIndex=0;for(const match of adjustedMatches){result+=escapeHtml(snippet.substring(lastIndex,match.start));result+=`${escapeHtml(snippet.substring(match.start,match.end))}`;lastIndex=match.end}result+=escapeHtml(snippet.substring(lastIndex));if(end0){const highlighted=highlightWithMatchInfo(value,matchInfo,highlightOptions);return highlighted!==value?highlighted:void 0}}return void 0}}function defaultHighlightMiddleware(prop,value,template,query,matchInfo){const middleware=createHighlightTemplateMiddleware();return middleware(prop,value,template,query,matchInfo)}function SimpleJekyllSearch(options2){const instance=new SimpleJekyllSearch$1;return instance.init(options2)}if(typeof window!=="undefined"){window.SimpleJekyllSearch=SimpleJekyllSearch;window.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware}exports2.DefaultHybridSearchStrategy=DefaultHybridSearchStrategy;exports2.HybridSearchStrategy=HybridSearchStrategy;exports2.StrategyFactory=StrategyFactory;exports2.createHighlightTemplateMiddleware=createHighlightTemplateMiddleware;exports2.default=SimpleJekyllSearch;exports2.defaultHighlightMiddleware=defaultHighlightMiddleware;exports2.escapeHtml=escapeHtml;exports2.highlightWithMatchInfo=highlightWithMatchInfo;exports2.mergeOverlappingMatches=mergeOverlappingMatches;Object.defineProperties(exports2,{__esModule:{value:true},[Symbol.toStringTag]:{value:"Module"}})})); \ No newline at end of file From 770577a1e191af7af81144a6329173cf816044ed Mon Sep 17 00:00:00 2001 From: sylhare Date: Thu, 23 Oct 2025 09:43:25 -0400 Subject: [PATCH 17/18] Update documentation --- README.md | 59 ++++++++++++++++++++++++++---- docs/get-started.md | 2 +- tests/Repository.test.ts | 78 ++++++++++++++++++++++++++++++++++++---- tests/Templater.test.ts | 39 ++++++++++++++++++++ 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 28da376..59dda48 100644 --- a/README.md +++ b/README.md @@ -103,24 +103,41 @@ Here is a table for the available options, usage questions, troubleshooting & gu ### 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 match information. If the function returns a non-undefined value, it gets replaced in the template. -This can be potentially useful for manipulating URLs etc. +**New Interface:** +```js +templateMiddleware(prop, value, template, query?, matchInfo?) +``` -Example: +- `prop`: The property name being processed from the JSON data. +- `value`: The property value +- `template`: The template string +- `query`: The search query (optional) +- `matchInfo`: Array of match information objects with start/end positions and match types (optional) +This can be useful for manipulating URLs, highlighting search terms, or custom formatting. + +**Basic Example:** ```js SimpleJekyllSearch({ // ...other config - templateMiddleware: function(prop, value, template) { - if (prop === 'bar') { - return value.replace(/^\//, '') + searchResultTemplate: '
  • {title}
  • ', + templateMiddleware: function(prop, value, template, query, matchInfo) { + if (prop === 'title') { + return value.toUpperCase() } }, }) ``` +**How it works:** +- Template: `'
  • {title}
  • '` +- When processing `{title}`: `prop = 'title'`, `value = 'my post'` → returns `'MY POST'` +- Final result: `'
  • MY POST
  • '` + + ### sortMiddleware (Function) [optional] A function that will be used to sort the filtered results. @@ -139,3 +156,33 @@ SimpleJekyllSearch({ }, }) ``` + +### Built-in Highlight Middleware (Function) [optional] + +Simple-Jekyll-Search now includes built-in highlighting functionality that can be easily integrated: + +```js +import { createHighlightTemplateMiddleware } from 'simple-jekyll-search/middleware'; + +SimpleJekyllSearch({ + // ...other config + templateMiddleware: createHighlightTemplateMiddleware({ + className: 'search-highlight', // CSS class for highlighted text + maxLength: 200, // Maximum length of highlighted content + contextLength: 30 // Characters of context around matches + }), +}) +``` + +**Highlight Options:** +- `className`: CSS class name for highlighted spans (default: 'search-highlight') +- `maxLength`: Maximum length of content to display (truncates with ellipsis) +- `contextLength`: Number of characters to show around matches when truncating + +**CSS Styling:** +```css +.search-highlight { + background-color: yellow; + font-weight: bold; +} +``` diff --git a/docs/get-started.md b/docs/get-started.md index 7ce8fa9..584e3d9 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -65,7 +65,7 @@ Customize SimpleJekyllSearch by passing in your configuration options: searchResultTemplate: '
  • {title}
  • ', noResultsText: 'No results found', limit: 10, - fuzzy: true, + strategy: 'fuzzy', exclude: ['Welcome'] }) diff --git a/tests/Repository.test.ts b/tests/Repository.test.ts index d696923..697b2c2 100644 --- a/tests/Repository.test.ts +++ b/tests/Repository.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Repository } from '../src/Repository'; -import { SearchResult } from '../src/utils/types'; interface TestElement { title: string; @@ -95,7 +94,7 @@ describe('Repository', () => { expect(repository.search('almostbar')).toEqual([]); }); - it('excludes items from search #2', () => { + it('sorts search results alphabetically by title', () => { repository.setOptions({ sortMiddleware: (a: TestElement, b: TestElement) => { return a.title.localeCompare(b.title); @@ -108,18 +107,53 @@ describe('Repository', () => { expect(results[2]).toMatchObject(loremElement); }); + it('uses default NoSort when no sortMiddleware provided', () => { + const results = repository.search('r'); + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject(barElement); + expect(results[1]).toMatchObject(almostBarElement); + expect(results[2]).toMatchObject(loremElement); + }); + + it('demonstrates README example: custom sorting by section and caption', () => { + const testData = [ + { section: 'Getting Started', caption: 'Installation', title: 'How to install' }, + { section: 'API Reference', caption: 'Methods', title: 'Available methods' }, + { section: 'Getting Started', caption: 'Configuration', title: 'How to configure' }, + { section: 'API Reference', caption: 'Properties', title: 'Object properties' } + ]; + + repository.put(testData); + repository.setOptions({ + sortMiddleware: (a: any, b: any) => { + const astr = String(a.section) + "-" + String(a.caption); + const bstr = String(b.section) + "-" + String(b.caption); + return astr.localeCompare(bstr); + }, + }); + + const results = repository.search('How'); + expect(results).toHaveLength(2); + // Should be sorted by section first, then caption + expect(results[0].section).toBe('Getting Started'); + expect(results[0].caption).toBe('Configuration'); + expect(results[1].section).toBe('Getting Started'); + expect(results[1].caption).toBe('Installation'); + }); + it('search results should be a clone and not a reference to repository data', () => { const query = 'Developer'; - repository.put( + const testData = [ { name: 'Alice', role: 'Developer' }, - { name: 'Bob', role: 'Designer' }, - ); + { name: 'Bob', role: 'Designer' } + ]; + repository.put(testData); const results = repository.search(query); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ name: 'Alice', role: 'Developer' }); - (results as SearchResult[]).forEach(result => { + (results as any[]).forEach(result => { result.role = 'Modified Role'; }); @@ -127,4 +161,36 @@ describe('Repository', () => { expect(originalData).toHaveLength(1); expect(originalData[0]).toMatchObject({ name: 'Alice', role: 'Developer' }); }); + + it('demonstrates README sortMiddleware example exactly', () => { + // This test matches the exact example from the README + const testData = [ + { section: 'API Reference', caption: 'Properties', title: 'Object properties' }, + { section: 'Getting Started', caption: 'Installation', title: 'How to install' }, + { section: 'API Reference', caption: 'Methods', title: 'Available methods' }, + { section: 'Getting Started', caption: 'Configuration', title: 'How to configure' } + ]; + + repository.put(testData); + repository.setOptions({ + sortMiddleware: function(a: any, b: any) { + var astr = String(a.section) + "-" + String(a.caption); + var bstr = String(b.section) + "-" + String(b.caption); + return astr.localeCompare(bstr); + }, + }); + + const results = repository.search('a'); // Search for 'a' to get all results + expect(results).toHaveLength(4); + + // Should be sorted by section first, then caption alphabetically + expect(results[0].section).toBe('API Reference'); + expect(results[0].caption).toBe('Methods'); + expect(results[1].section).toBe('API Reference'); + expect(results[1].caption).toBe('Properties'); + expect(results[2].section).toBe('Getting Started'); + expect(results[2].caption).toBe('Configuration'); + expect(results[3].section).toBe('Getting Started'); + expect(results[3].caption).toBe('Installation'); + }); }); \ No newline at end of file diff --git a/tests/Templater.test.ts b/tests/Templater.test.ts index a0472ac..ea6d8b7 100644 --- a/tests/Templater.test.ts +++ b/tests/Templater.test.ts @@ -137,4 +137,43 @@ describe('Templater', () => { const compiled = templater.compile({ foo: 'bar' }); expect(compiled).toBe('bar'); }); + + it('demonstrates README example: uppercase title middleware', () => { + templater.setOptions({ + template: '
  • {title}
  • ', + middleware(prop: string, value: string) { + if (prop === 'title') { + return value.toUpperCase(); + } + return undefined + } + }); + + const data = { title: 'my post' }; + const compiled = templater.compile(data); + expect(compiled).toBe('
  • MY POST
  • '); + }); + + it('demonstrates multiple property processing with different transformations', () => { + templater.setOptions({ + template: '
  • {title}

    {desc}

  • ', + middleware(prop: string, value: string) { + if (prop === 'url') { + return value.replace(/^\//, ''); // Remove leading slash + } + if (prop === 'title') { + return value.toUpperCase(); + } + return undefined; + } + }); + + const data = { + url: '/blog/post', + title: 'my post', + desc: 'description' + }; + const compiled = templater.compile(data); + expect(compiled).toBe('
  • MY POST

    description

  • '); + }); }); \ No newline at end of file From bde4c90378a449a5e4490d87ce63dcef81742e64 Mon Sep 17 00:00:00 2001 From: sylhare Date: Fri, 14 Nov 2025 15:44:11 -0500 Subject: [PATCH 18/18] Add build step to start docs --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3107301..1be53af 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:benchmark": "NODE_OPTIONS='--max-old-space-size=4096' vitest run tests/performance/", "test:watch": "vitest", "start": "cd docs; jekyll serve", + "prestart:docs": "yarn run build", "start:docs": "cd docs && bundle exec jekyll serve" }, "repository": {