diff --git a/README.md b/README.md index 18e48ee..6f484d7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Lightweight and performant Lucene-like parser and search engine. * [Wildcard matching](#wildcard-matching) * [Logical Operators](#logical-operators) * [Compatibility with Lucene](#compatibility-with-lucene) +* [Recipes](#recipes) + * [Highlighting matches](#highlighting-matches) * [Development](#development) ## Usage @@ -76,8 +78,8 @@ Highlight matching fields and substrings: test(highlight('name:john'), persons[0]); // [ // { -// keyword: 'John', // path: 'name', +// query: /(John)/, // } // ] test(highlight('height:180'), persons[0]); @@ -242,6 +244,12 @@ The following Lucene abilities are not supported: * [Proximity Searches](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Proximity%20Searches) * [Boosting a Term](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html#Boosting%20a%20Term) +## Recipes + +### Highlighting matches + +Consider using [`highlight-words`](https://github.com/tricinel/highlight-words) package to highlight Liqe matches. + ## Development ### Compiling Parser diff --git a/src/highlight.ts b/src/highlight.ts index 2aca6c4..1ee8526 100644 --- a/src/highlight.ts +++ b/src/highlight.ts @@ -1,16 +1,25 @@ +import { + escapeRegexString, +} from './escapeRegexString'; import { internalFilter, } from './internalFilter'; import type { Ast, Highlight, + InternalHighlight, } from './types'; +type AggregatedHighlight = { + keywords: string[], + path: string, +}; + export const highlight = ( ast: Ast, data: T, ): Highlight[] => { - const highlights = []; + const highlights: InternalHighlight[] = []; internalFilter( ast, @@ -20,5 +29,39 @@ export const highlight = ( highlights, ); - return highlights; + const aggregatedHighlights: AggregatedHighlight[] = []; + + for (const highlightNode of highlights) { + let aggregatedHighlight = aggregatedHighlights.find((maybeTarget) => { + return maybeTarget.path === highlightNode.path; + }); + + if (!aggregatedHighlight) { + aggregatedHighlight = { + keywords: [], + path: highlightNode.path, + }; + + aggregatedHighlights.push(aggregatedHighlight); + } + + if (highlightNode.keyword) { + aggregatedHighlight.keywords.push(highlightNode.keyword); + } + } + + return aggregatedHighlights.map((aggregatedHighlight) => { + if (aggregatedHighlight.keywords.length > 0) { + return { + path: aggregatedHighlight.path, + query: new RegExp('(' + aggregatedHighlight.keywords.map((keyword) => { + return escapeRegexString(keyword.trim()); + }).join('|') + ')'), + }; + } + + return { + path: aggregatedHighlight.path, + }; + }); }; diff --git a/src/internalFilter.ts b/src/internalFilter.ts index 583ae69..e84a052 100644 --- a/src/internalFilter.ts +++ b/src/internalFilter.ts @@ -9,7 +9,7 @@ import { } from './parseRegex'; import type { Ast, - Highlight, + InternalHighlight, InternalTest, Range, RelationalOperator, @@ -121,7 +121,7 @@ const testValue = ( value: unknown, resultFast: boolean, path: string[], - highlights: Highlight[], + highlights: InternalHighlight[], ) => { if (Array.isArray(value)) { let foundMatch = false; @@ -181,7 +181,7 @@ const testField = ( ast: Ast, resultFast: boolean, path: string[], - highlights: Highlight[], + highlights: InternalHighlight[], ): boolean => { if (!ast.test) { ast.test = createValueTest(ast); @@ -251,7 +251,7 @@ export const internalFilter = ( rows: readonly T[], resultFast: boolean = true, path: string[] = [], - highlights: Highlight[] = [], + highlights: InternalHighlight[] = [], ): readonly T[] => { if (ast.field) { return rows.filter((row) => { diff --git a/src/types.ts b/src/types.ts index 36309ea..9267c19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,9 +21,14 @@ export type Ast = { test?: InternalTest, }; -export type Highlight = { +export type InternalHighlight = { keyword?: string, path: string, }; +export type Highlight = { + path: string, + query?: RegExp, +}; + export type InternalTest = (value: unknown) => boolean | string; diff --git a/test/liqe/highlight.ts b/test/liqe/highlight.ts index 05e3c12..78a411f 100644 --- a/test/liqe/highlight.ts +++ b/test/liqe/highlight.ts @@ -23,11 +23,11 @@ test.skip( }, [ { - keyword: 'foo@bar.com', path: 'email', + query: /(foo@bar.com)/, }, { - keyword: 'foo bar', + keyword: /(foo bar)/, path: 'name', }, ], @@ -43,12 +43,12 @@ test( }, [ { - keyword: 'foo', path: 'email', + query: /(foo)/, }, { - keyword: 'foo', path: 'name', + query: /(foo)/, }, ], ); @@ -62,8 +62,8 @@ test( }, [ { - keyword: 'foo', path: 'name', + query: /(foo)/, }, ], ); @@ -77,8 +77,8 @@ test( }, [ { - keyword: 'Foo', path: 'name', + query: /(Foo)/, }, ], ); @@ -93,8 +93,8 @@ test( }, [ { - keyword: 'bar', path: 'name', + query: /(bar)/, }, { path: 'height', @@ -111,8 +111,8 @@ test( }, [ { - keyword: 'foo', path: 'name', + query: /(foo)/, }, ], ); @@ -126,8 +126,8 @@ test( }, [ { - keyword: 'foo', path: 'name', + query: /(foo)/, }, ], ); @@ -141,8 +141,8 @@ test( }, [ { - keyword: 'foo', path: 'name', + query: /(foo)/, }, ], ); @@ -156,11 +156,11 @@ test.skip( }, [ { - keyword: 'foo', path: 'name', + query: /(foo)/, }, { - keyword: 'bar', + keyword: /(bar)/, path: 'name', }, ], @@ -221,8 +221,8 @@ test( }, [ { - keyword: 'bar', path: 'tags.1', + query: /(bar)/, }, ], ); @@ -240,12 +240,12 @@ test( }, [ { - keyword: 'ba', path: 'tags.1', + query: /(ba)/, }, { - keyword: 'ba', path: 'tags.2', + query: /(ba)/, }, ], ); @@ -279,8 +279,53 @@ test( }, [ { - keyword: 'foo', path: 'name', + query: /(foo)/, + }, + ], +); + +test( + 'aggregates multiple highlights', + testQuery, + 'foo AND bar AND baz', + { + name: 'foo bar baz', + }, + [ + { + path: 'name', + query: /(foo|bar|baz)/, + }, + ], +); + +test( + 'aggregates multiple highlights (phrases)', + testQuery, + '"foo bar" AND baz', + { + name: 'foo bar baz', + }, + [ + { + path: 'name', + query: /(foo bar|baz)/, + }, + ], +); + +test( + 'aggregates multiple highlights (escaping)', + testQuery, + '"(foo bar)" AND baz', + { + name: '(foo bar) baz', + }, + [ + { + path: 'name', + query: /(\(foo bar\)|baz)/, }, ], );