Skip to content

Commit

Permalink
Merge pull request #8 from oramasearch/feat/trim
Browse files Browse the repository at this point in the history
BREAKING: Adds trim function (implements #2)
  • Loading branch information
micheleriva committed Oct 22, 2023
2 parents 0121351 + 14ec880 commit 68175f3
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 86 deletions.
10 changes: 2 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 42 additions & 12 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -13,24 +13,30 @@ describe('default configuration', () => {
const searchTerm2 = 'yesterday I was in trouble'
const expectedResult2 = '<mark class="orama-highlight">Yesterday</mark> all my <mark class="orama-highlight">trouble</mark>s seemed so far away, now <mark class="orama-highlight">i</mark>t looks as though they\'re here to stay oh, <mark class="orama-highlight">I</mark> bel<mark class="orama-highlight">i</mark>eve <mark class="orama-highlight">i</mark>n <mark class="orama-highlight">yesterday</mark>'

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', () => {
const text = 'The quick brown fox jumps over the lazy dog'
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', () => {
const text = 'The quick brown fox jumps over the lazy dog'
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)
})
})

Expand All @@ -44,32 +50,40 @@ describe('custom configuration', () => {
const searchTerm2 = 'yesterday I was in trouble'
const expectedResult2 = 'Yesterday all my <mark class="orama-highlight">trouble</mark>s seemed so far away, now it looks as though they\'re here to stay oh, <mark class="orama-highlight">I</mark> believe <mark class="orama-highlight">in</mark> <mark class="orama-highlight">yesterday</mark>'

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', () => {
const text = 'The quick brown fox jumps over the lazy dog'
const searchTerm = 'fox'
const expectedResult = 'The quick brown <mark class="custom-class">fox</mark> 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', () => {
const text = 'The quick brown fox jumps over the lazy dog'
const searchTerm = 'fox'
const expectedResult = 'The quick brown <div class="orama-highlight">fox</div> 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', () => {
const text = 'The quick brown fox jumps over the lazy dog'
const searchTerm = 'fox jump'
const expectedResult = 'The quick brown <mark class="orama-highlight">fox</mark> 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)
})
})

Expand All @@ -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 <mark class="orama-highlight">fox</mark> j...')
assert.strictEqual(highlighter.highlight(text, searchTerm).trim(5), '...n <mark class="orama-highlight">fox</mark>...')
assert.strictEqual(highlighter.highlight(text, 'the').trim(5), '<mark class="orama-highlight">The</mark> q...')
assert.strictEqual(highlighter.highlight(text, 'dog').trim(5), '...y <mark class="orama-highlight">dog</mark>')
assert.strictEqual(highlighter.highlight(text, 'dog').trim(5, false), 'y <mark class="orama-highlight">dog</mark>')
assert.strictEqual(highlighter.highlight(text, 'the').trim(5, false), '<mark class="orama-highlight">The</mark> q')
})
})
104 changes: 69 additions & 35 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,93 @@
interface Highlight {
positions: Array<{ start: number, end: number }>
toString: () => string
}

export interface HighlightOptions {
caseSensitive?: boolean
wholeWords?: boolean
HTMLTag?: string
CSSClass?: string
}

type Positions = Array<{ start: number, end: number }>

const defaultOptions: HighlightOptions = {
caseSensitive: false,
wholeWords: false,
HTMLTag: 'mark',
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]}</${HTMLTag}>`)

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]}</${HTMLTag}>`)
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
}
}
14 changes: 0 additions & 14 deletions src/react/index.tsx

This file was deleted.

17 changes: 0 additions & 17 deletions tsup.react.js

This file was deleted.

0 comments on commit 68175f3

Please sign in to comment.