diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 268f8ad..ca301ab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: cache: 'yarn' - name: Install dependencies run: yarn install --frozen-lockfile - - name: Run tests - run: yarn test + - name: Run unit tests + run: yarn run test:unit - name: Build application run: yarn run build \ No newline at end of file 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/cypress/e2e/simple-jekyll-search.cy.ts b/cypress/e2e/simple-jekyll-search.cy.ts index 94d17f2..4d18015 100644 --- a/cypress/e2e/simple-jekyll-search.cy.ts +++ b/cypress/e2e/simple-jekyll-search.cy.ts @@ -25,10 +25,10 @@ describe('Simple Jekyll Search', () => { it('No results found', () => { cy.get('#search-input') - .type('random'); + .type('xyzabc123notfound'); cy.get('#results-container') - .contains('No results found'); + .should('contain', 'No results found'); }); describe('Search Functionality Edge cases', () => { @@ -60,4 +60,94 @@ describe('Simple Jekyll Search', () => { .should('exist'); }); }); + + describe('Hybrid Strategy with Highlighting', () => { + it('should use literal search and highlight exact matches', () => { + cy.get('#search-input') + .type('Lorem'); + + cy.get('#results-container') + .should('be.visible'); + + // Should find the "This is just a test" post + cy.get('#results-container').contains('This is just a test').should('exist'); + + cy.get('#results-container .search-desc .search-highlight') + .should('exist') + .should('have.css', 'background-color', 'rgb(255, 255, 0)'); + + // Find highlights that contain Lorem (may be in multiple results) + cy.get('#results-container .search-desc .search-highlight') + .filter(':contains("Lorem")') + .should('have.length.at.least', 1); + }); + + it('should use literal search for multi-word queries and highlight', () => { + cy.get('#search-input') + .type('Lorem ipsum'); + + cy.get('#results-container') + .should('be.visible'); + + // Should find the "This is just a test" post + cy.get('#results-container').contains('This is just a test').should('exist'); + + cy.get('#results-container .search-desc .search-highlight') + .should('have.length.at.least', 1); + + // Check that Lorem is highlighted somewhere in the results + cy.get('#results-container .search-desc .search-highlight') + .filter(':contains("Lorem")') + .should('exist'); + }); + + it('should handle different search patterns with hybrid strategy', () => { + // Test single word search (uses fuzzy/literal) + cy.get('#search-input') + .clear() + .type('ipsum'); + + cy.get('#results-container li') + .should('have.length.at.least', 1); + + cy.get('#results-container') + .should('contain.text', 'ipsum'); + }); + + it('should handle partial matches with hybrid strategy', () => { + // Test another single word + cy.get('#search-input') + .clear() + .type('technical'); + + cy.get('#results-container li') + .should('have.length.at.least', 1); + + cy.get('#results-container') + .should('contain.text', 'Technical'); + }); + + it('should highlight multiple occurrences in literal search', () => { + 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', ' + 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/docs/_sass/_custom.scss b/docs/_sass/_custom.scss index 4cf1d2a..1be608d 100644 --- a/docs/_sass/_custom.scss +++ b/docs/_sass/_custom.scss @@ -62,4 +62,16 @@ $on-laptop: 800px; .info-text { margin-top: 20px; +} + +.search-highlight { + background-color: #ffff00; + font-weight: bold; + padding: 0 2px; +} + +.search-desc { + font-size: 0.9em; + color: $grey-color-dark; + margin: 0.3em 0; } \ 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 0d7044f..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 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/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/package.json b/package.json index f33ffec..1be53af 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,25 @@ { "name": "simple-jekyll-search", - "version": "1.15.2", - "description": "Fork of Simple Jekyll Search from https://github.com/christian-fei/Simple-Jekyll-Search", + "version": "2.0.0", + "description": "A simple JavaScript library to add search functionality to any Jekyll blog - Fast, lightweight, client-side search. Fork of Simple Jekyll Search from https://github.com/christian-fei/Simple-Jekyll-Search", "main": "dest/simple-jekyll-search.js", "type": "module", "scripts": { "cypress": "cypress run", "cypress:run": "node scripts/start-jekyll.js && sleep 8 && cypress run; node scripts/kill-jekyll.js", "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", "pretest": "yarn run lint", "build": "tsc && vite build && terser dest/simple-jekyll-search.js -o dest/simple-jekyll-search.min.js", "prebuild": "yarn run test", "postbuild": "node scripts/stamp.js < dest/simple-jekyll-search.min.js > dest/simple-jekyll-search.min.js.tmp && mv dest/simple-jekyll-search.min.js.tmp dest/simple-jekyll-search.min.js && yarn run copy-example-code", "copy-example-code": "cp dest/simple-jekyll-search.min.js docs/assets/js/", "test": "vitest run --coverage", + "test:unit": "vitest run --exclude '**/performance/**' --coverage", + "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": { 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 diff --git a/src/Repository.ts b/src/Repository.ts index de6b96c..3e9cf04 100644 --- a/src/Repository.ts +++ b/src/Repository.ts @@ -1,12 +1,14 @@ import { FuzzySearchStrategy, LiteralSearchStrategy, WildcardSearchStrategy } from './SearchStrategies/SearchStrategy'; import { Matcher } from './SearchStrategies/types'; -import { clone, isObject } from './utils'; +import { StrategyFactory, StrategyType } from './SearchStrategies/StrategyFactory'; +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; + private options!: Required> & Pick; + private excludePatterns: RegExp[] = []; constructor(initialOptions: RepositoryOptions = {}) { this.setOptions(initialOptions); @@ -31,17 +33,27 @@ 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 { + // 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'; + } + + const exclude = newOptions?.exclude || DEFAULT_OPTIONS.exclude; + this.excludePatterns = exclude.map(pattern => new RegExp(pattern)); + 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, + exclude: exclude, + strategy: strategy, }; } @@ -73,26 +85,37 @@ 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 { - 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( - 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/SearchStrategy.ts b/src/SearchStrategies/SearchStrategy.ts index e51f266..26eafb3 100644 --- a/src/SearchStrategies/SearchStrategy.ts +++ b/src/SearchStrategies/SearchStrategy.ts @@ -1,12 +1,28 @@ -import { fuzzySearch } from './search/fuzzySearch'; -import { literalSearch } from './search/literalSearch'; -import { wildcardSearch } from './search/wildcardSearch'; +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); -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( + findLiteralMatches +); + +export const FuzzySearchStrategy = new SearchStrategy( + (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) => { + 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/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/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..0a2b688 --- /dev/null +++ b/src/SearchStrategies/search/findLiteralMatches.ts @@ -0,0 +1,43 @@ +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) { + 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; +} + 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 8228475..0000000 --- a/src/SearchStrategies/search/wildcardSearch.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 - */ -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); -} \ No newline at end of file diff --git a/src/SearchStrategies/types.ts b/src/SearchStrategies/types.ts index c1715e0..d2aa5b7 100644 --- a/src/SearchStrategies/types.ts +++ b/src/SearchStrategies/types.ts @@ -1,12 +1,20 @@ +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) { - this.matchFunction = matchFunction; + constructor(findMatchesFunction: (text: string, criteria: string) => MatchInfo[]) { + this.findMatchesFunction = findMatchesFunction; } matches(text: string | null, criteria: string): boolean { @@ -14,6 +22,15 @@ export class SearchStrategy implements Matcher { return false; } - return this.matchFunction(text, criteria); + const matchInfo = this.findMatchesFunction(text, criteria); + return matchInfo.length > 0; + } + + findMatches(text: string | null, criteria: string): MatchInfo[] { + if (text === null || text.trim() === '' || !criteria) { + return []; + } + + return this.findMatchesFunction(text, criteria); } } \ No newline at end of file diff --git a/src/SimpleJekyllSearch.ts b/src/SimpleJekyllSearch.ts index c7822b4..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 { @@ -84,7 +99,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); }); @@ -105,7 +120,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/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 c2ba8af..8804ac0 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(); @@ -7,8 +8,18 @@ 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'; + +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; + (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.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/src/utils/default.ts b/src/utils/default.ts index fcb4c0e..0c169e6 100644 --- a/src/utils/default.ts +++ b/src/utils/default.ts @@ -11,11 +11,12 @@ export const DEFAULT_OPTIONS: Required = { sortMiddleware: NoSort, noResultsText: 'No results found', limit: 10, - fuzzy: false, strategy: 'literal', debounceTime: null, exclude: [], - onSearch: () => {} + onSearch: () => {}, + onError: (error: Error) => console.error('SimpleJekyllSearch error:', error), + fuzzy: false // Deprecated, use strategy: 'fuzzy' instead }; export const REQUIRED_OPTIONS = ['searchInput', 'resultsContainer', 'json']; diff --git a/src/utils/types.ts b/src/utils/types.ts index 6953478..b5133e6 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; @@ -27,6 +27,7 @@ export interface RepositoryOptions { export interface RepositoryData { [key: string]: any; + _matchInfo?: Record; } export interface SearchOptions extends Omit { @@ -35,10 +36,17 @@ 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; + onError?: (error: Error) => void; } export interface SimpleJekyllSearchInstance { diff --git a/tests/Repository.test.ts b/tests/Repository.test.ts index 10bd50a..697b2c2 100644 --- a/tests/Repository.test.ts +++ b/tests/Repository.test.ts @@ -1,6 +1,5 @@ -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'; interface TestElement { title: string; @@ -26,32 +25,62 @@ 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', () => { + 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 }); - 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(); + expect(consoleWarnSpy).toHaveBeenCalledWith('[Simple Jekyll Search] Warning: fuzzy option is deprecated. Use strategy: "fuzzy" instead.'); + + consoleWarnSpy.mockRestore(); }); 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('* ispum')).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', () => { @@ -65,30 +94,103 @@ 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); }, }); - 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('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).toEqual([{ name: 'Alice', role: 'Developer' }]); + 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'; }); const originalData = repository.search(query); - expect(originalData).toEqual([{ name: 'Alice', role: 'Developer' }]); + 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/SearchStrategies/HybridSearchStrategy.test.ts b/tests/SearchStrategies/HybridSearchStrategy.test.ts new file mode 100644 index 0000000..51773de --- /dev/null +++ b/tests/SearchStrategies/HybridSearchStrategy.test.ts @@ -0,0 +1,158 @@ +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('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); + }); + }); +}); + 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/findWildcardMatches.test.ts b/tests/SearchStrategies/findWildcardMatches.test.ts new file mode 100644 index 0000000..09ec446 --- /dev/null +++ b/tests/SearchStrategies/findWildcardMatches.test.ts @@ -0,0 +1,54 @@ +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('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 5dc8883..0000000 --- a/tests/SearchStrategies/wildcardSearch.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { wildcardSearch } from '../../src/SearchStrategies/search/wildcardSearch'; - -describe('wildcardFuzzySearch', () => { - 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); - }); - - 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 - }); - - it('should return false for matches below the similarity threshold', () => { - 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/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 diff --git a/tests/Templater.test.ts b/tests/Templater.test.ts index fb2feed..ea6d8b7 100644 --- a/tests/Templater.test.ts +++ b/tests/Templater.test.ts @@ -59,4 +59,121 @@ 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'); + }); + + 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 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'); + }); +}); + 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); }); }); diff --git a/vite.config.ts b/vite.config.ts index 02d7fc6..13a6914 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ test: { coverage: { provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], + reporter: ['text', 'lcov'], include: ['src/**/*.ts'], exclude: [ '**/*.d.ts',