diff --git a/package.json b/package.json index 743fd58..afb366e 100644 --- a/package.json +++ b/package.json @@ -13,19 +13,13 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts", "browser": "./dist/index.global.js" - }, - "./react": { - "require": "./dist/react/index.cjs", - "import": "./dist/react/index.js", - "types": "./dist/react/index.d.ts" } }, "scripts": { "test": "bun test", "lint": "ts-standard --fix ./src/**/*.ts", - "build": "npm run build:lib && npm run build:react", - "build:lib": "tsup --config tsup.lib.js", - "build:react": "tsup --config tsup.react.js" + "build": "npm run build:lib", + "build:lib": "tsup --config tsup.lib.js" }, "keywords": [ "full-text search", diff --git a/src/index.test.ts b/src/index.test.ts index 7a72256..6afcf53 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,7 +1,7 @@ import { describe, it, beforeEach, afterEach } from 'bun:test' import assert from 'node:assert' import sinon from 'sinon' -import { highlight } from './index.js' +import { Highlight } from './index.js' describe('default configuration', () => { it('should correctly highlight a text', () => { @@ -13,8 +13,10 @@ describe('default configuration', () => { const searchTerm2 = 'yesterday I was in trouble' const expectedResult2 = 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday' - assert.strictEqual(highlight(text1, searchTerm1).toString(), expectedResult1) - assert.strictEqual(highlight(text2, searchTerm2).toString(), expectedResult2) + const highlighter = new Highlight() + + assert.strictEqual(highlighter.highlight(text1, searchTerm1).HTML, expectedResult1) + assert.strictEqual(highlighter.highlight(text2, searchTerm2).HTML, expectedResult2) }) it('should return the correct positions', () => { @@ -22,7 +24,9 @@ describe('default configuration', () => { const searchTerm = 'fox' const expectedPositions = [{ start: 16, end: 18 }] - assert.deepStrictEqual(highlight(text, searchTerm).positions, expectedPositions) + const highlighter = new Highlight() + + assert.deepStrictEqual(highlighter.highlight(text, searchTerm).positions, expectedPositions) }) it('should return multiple positions', () => { @@ -30,7 +34,9 @@ describe('default configuration', () => { const searchTerm = 'the' const expectedPositions = [{ start: 0, end: 2 }, { start: 31, end: 33 }] - assert.deepStrictEqual(highlight(text, searchTerm).positions, expectedPositions) + const highlighter = new Highlight() + + assert.deepStrictEqual(highlighter.highlight(text, searchTerm).positions, expectedPositions) }) }) @@ -44,8 +50,10 @@ describe('custom configuration', () => { const searchTerm2 = 'yesterday I was in trouble' const expectedResult2 = 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday' - assert.strictEqual(highlight(text1, searchTerm1, { caseSensitive: true }).toString(), expectedResult1) - assert.strictEqual(highlight(text2, searchTerm2, { caseSensitive: true }).toString(), expectedResult2) + const highlighter = new Highlight({ caseSensitive: true }) + + assert.strictEqual(highlighter.highlight(text1, searchTerm1).HTML, expectedResult1) + assert.strictEqual(highlighter.highlight(text2, searchTerm2).HTML, expectedResult2) }) it('should correctly set a custom CSS class', () => { @@ -53,7 +61,9 @@ describe('custom configuration', () => { const searchTerm = 'fox' const expectedResult = 'The quick brown fox jumps over the lazy dog' - assert.strictEqual(highlight(text, searchTerm, { CSSClass: 'custom-class' }).toString(), expectedResult) + const highlighter = new Highlight({ CSSClass: 'custom-class' }) + + assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) }) it('should correctly use a custom HTML tag', () => { @@ -61,7 +71,9 @@ describe('custom configuration', () => { const searchTerm = 'fox' const expectedResult = 'The quick brown
fox
jumps over the lazy dog' - assert.strictEqual(highlight(text, searchTerm, { HTMLTag: 'div' }).toString(), expectedResult) + const highlighter = new Highlight({ HTMLTag: 'div' }) + + assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) }) it('should correctly highlight whole words only', () => { @@ -69,7 +81,9 @@ describe('custom configuration', () => { const searchTerm = 'fox jump' const expectedResult = 'The quick brown fox jumps over the lazy dog' - assert.strictEqual(highlight(text, searchTerm, { wholeWords: true }).toString(), expectedResult) + const highlighter = new Highlight({ wholeWords: true }) + + assert.strictEqual(highlighter.highlight(text, searchTerm).HTML, expectedResult) }) }) @@ -94,10 +108,26 @@ describe('highlight function - infinite loop protection', () => { return null }) - const result = highlight(text, searchTerm) + const highlighter = new Highlight() + const result = highlighter.highlight(text, searchTerm) - assert.strictEqual(result.toString(), text) + assert.strictEqual(result.HTML, text) assert(regexExecStub.called) }) }) + +describe('trim method', () => { + it('should correctly trim the text', () => { + const text = 'The quick brown fox jumps over the lazy dog' + const searchTerm = 'fox' + const highlighter = new Highlight() + + assert.strictEqual(highlighter.highlight(text, searchTerm).trim(10), '...rown fox j...') + assert.strictEqual(highlighter.highlight(text, searchTerm).trim(5), '...n fox...') + assert.strictEqual(highlighter.highlight(text, 'the').trim(5), 'The q...') + assert.strictEqual(highlighter.highlight(text, 'dog').trim(5), '...y dog') + assert.strictEqual(highlighter.highlight(text, 'dog').trim(5, false), 'y dog') + assert.strictEqual(highlighter.highlight(text, 'the').trim(5, false), 'The q') + }) +}) diff --git a/src/index.ts b/src/index.ts index f88d97e..8729227 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,3 @@ -interface Highlight { - positions: Array<{ start: number, end: number }> - toString: () => string -} - export interface HighlightOptions { caseSensitive?: boolean wholeWords?: boolean @@ -10,6 +5,8 @@ export interface HighlightOptions { CSSClass?: string } +type Positions = Array<{ start: number, end: number }> + const defaultOptions: HighlightOptions = { caseSensitive: false, wholeWords: false, @@ -17,43 +14,80 @@ const defaultOptions: HighlightOptions = { CSSClass: 'orama-highlight' } -export function highlight (text: string, searchTerm: string, options: HighlightOptions = defaultOptions): Highlight { - const caseSensitive = options.caseSensitive ?? defaultOptions.caseSensitive - const wholeWords = options.wholeWords ?? defaultOptions.wholeWords - const HTMLTag = options.HTMLTag ?? defaultOptions.HTMLTag - const CSSClass = options.CSSClass ?? defaultOptions.CSSClass - const regexFlags = caseSensitive ? 'g' : 'gi' - const boundary = wholeWords ? '\\b' : '' - const searchTerms = (caseSensitive ? searchTerm : searchTerm.toLowerCase()).trim().split(/\s+/).join('|') - const regex = new RegExp(`${boundary}${searchTerms}${boundary}`, regexFlags) - const positions: Array<{ start: number, end: number }> = [] - const highlightedParts: string[] = [] - - let match - let lastEnd = 0 - let previousLastIndex = -1 - - while ((match = regex.exec(text)) !== null) { - if (regex.lastIndex === previousLastIndex) { - break +export class Highlight { + private readonly options: HighlightOptions + private _positions: Positions = [] + private _HTML: string = '' + private _searchTerm: string = '' + private _originalText: string = '' + + constructor (options: HighlightOptions = defaultOptions) { + this.options = { ...defaultOptions, ...options } + } + + public highlight (text: string, searchTerm: string): Highlight { + this._searchTerm = searchTerm + this._originalText = text + + const caseSensitive = this.options.caseSensitive ?? defaultOptions.caseSensitive + const wholeWords = this.options.wholeWords ?? defaultOptions.wholeWords + const HTMLTag = this.options.HTMLTag ?? defaultOptions.HTMLTag + const CSSClass = this.options.CSSClass ?? defaultOptions.CSSClass + const regexFlags = caseSensitive ? 'g' : 'gi' + const boundary = wholeWords ? '\\b' : '' + const searchTerms = (caseSensitive ? searchTerm : searchTerm.toLowerCase()).trim().split(/\s+/).join('|') + const regex = new RegExp(`${boundary}${searchTerms}${boundary}`, regexFlags) + const positions: Array<{ start: number, end: number }> = [] + const highlightedParts: string[] = [] + + let match + let lastEnd = 0 + let previousLastIndex = -1 + + while ((match = regex.exec(text)) !== null) { + if (regex.lastIndex === previousLastIndex) { + break + } + previousLastIndex = regex.lastIndex + + const start = match.index + const end = start + match[0].length - 1 + + positions.push({ start, end }) + + highlightedParts.push(text.slice(lastEnd, start)) + highlightedParts.push(`<${HTMLTag} class="${CSSClass}">${match[0]}`) + + lastEnd = end + 1 } - previousLastIndex = regex.lastIndex - const start = match.index - const end = start + match[0].length - 1 + highlightedParts.push(text.slice(lastEnd)) - positions.push({ start, end }) + this._positions = positions + this._HTML = highlightedParts.join('') - highlightedParts.push(text.slice(lastEnd, start)) - highlightedParts.push(`<${HTMLTag} class="${CSSClass}">${match[0]}`) + return this + } - lastEnd = end + 1 + public trim (trimLength: number, ellipsis: boolean = true): string { + if (this._positions.length === 0 || this._originalText.length <= trimLength) { + return this._HTML + } + + const firstMatch = this._positions[0].start + const start = Math.max(firstMatch - Math.floor(trimLength / 2), 0) + const end = Math.min(start + trimLength, this._originalText.length) + const trimmedContent = `${start === 0 || !ellipsis ? '' : '...'}${this._originalText.slice(start, end)}${end < this._originalText.length && ellipsis ? '...' : ''}` + + this.highlight(trimmedContent, this._searchTerm) + return this._HTML } - highlightedParts.push(text.slice(lastEnd)) + get positions (): Positions { + return this._positions + } - return { - positions, - toString: () => highlightedParts.join('') + get HTML (): string { + return this._HTML } } diff --git a/src/react/index.tsx b/src/react/index.tsx deleted file mode 100644 index f84b5d3..0000000 --- a/src/react/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { FC } from 'react' -import type { HighlightOptions } from '../index.js' -import { highlight } from '../index.js' - -interface HighlightProps { - text: string - searchTerm: string - options?: HighlightOptions -} - -export const Highlight: FC = ({ text, searchTerm, options }) => { - const { toString } = highlight(text, searchTerm, options) - return toString() -} \ No newline at end of file diff --git a/tsup.react.js b/tsup.react.js deleted file mode 100644 index 5afdb4f..0000000 --- a/tsup.react.js +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'tsup' - -const entry = new URL('src/react/index.tsx', import.meta.url).pathname -const outDir = new URL('dist/react', import.meta.url).pathname - -export default defineConfig({ - entry: [entry], - splitting: false, - sourcemap: true, - minify: true, - external: ['react'], - format: ['cjs', 'esm'], - dts: true, - clean: true, - bundle: true, - outDir -})