├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── bun.lockb ├── bunfig.toml ├── package.json ├── src ├── index.test.ts └── index.ts ├── tsconfig.json └── tsup.lib.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: oven-sh/setup-bun@v1 15 | with: 16 | bun-version: latest 17 | - run: bun install 18 | - run: bun run build 19 | - run: bun test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,osx,windows,linux 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,osx,windows,linux 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | lerna-debug.log* 27 | .pnpm-debug.log* 28 | 29 | # Diagnostic reports (https://nodejs.org/api/report.html) 30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | *.lcov 44 | 45 | # nyc test coverage 46 | .nyc_output 47 | 48 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 49 | .grunt 50 | 51 | # Bower dependency directory (https://bower.io/) 52 | bower_components 53 | 54 | # node-waf configuration 55 | .lock-wscript 56 | 57 | # Compiled binary addons (https://nodejs.org/api/addons.html) 58 | build/Release 59 | 60 | # Dependency directories 61 | node_modules/ 62 | jspm_packages/ 63 | 64 | # Snowpack dependency directory (https://snowpack.dev/) 65 | web_modules/ 66 | 67 | # TypeScript cache 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | .npm 72 | 73 | # Optional eslint cache 74 | .eslintcache 75 | 76 | # Optional stylelint cache 77 | .stylelintcache 78 | 79 | # Microbundle cache 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | .node_repl_history 87 | 88 | # Output of 'npm pack' 89 | *.tgz 90 | 91 | # Yarn Integrity file 92 | .yarn-integrity 93 | 94 | # dotenv environment variable files 95 | .env 96 | .env.development.local 97 | .env.test.local 98 | .env.production.local 99 | .env.local 100 | 101 | # parcel-bundler cache (https://parceljs.org/) 102 | .cache 103 | .parcel-cache 104 | 105 | # Next.js build output 106 | .next 107 | out 108 | 109 | # Nuxt.js build / generate output 110 | .nuxt 111 | dist 112 | 113 | # Gatsby files 114 | .cache/ 115 | # Comment in the public line in if your project uses Gatsby and not Next.js 116 | # https://nextjs.org/blog/next-9-1#public-directory-support 117 | # public 118 | 119 | # vuepress build output 120 | .vuepress/dist 121 | 122 | # vuepress v2.x temp and cache directory 123 | .temp 124 | 125 | # Docusaurus cache and generated files 126 | .docusaurus 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | # yarn v2 144 | .yarn/cache 145 | .yarn/unplugged 146 | .yarn/build-state.yml 147 | .yarn/install-state.gz 148 | .pnp.* 149 | 150 | ### Node Patch ### 151 | # Serverless Webpack directories 152 | .webpack/ 153 | 154 | # Optional stylelint cache 155 | 156 | # SvelteKit build / generate output 157 | .svelte-kit 158 | 159 | ### OSX ### 160 | # General 161 | .DS_Store 162 | .AppleDouble 163 | .LSOverride 164 | 165 | # Icon must end with two \r 166 | Icon 167 | 168 | 169 | # Thumbnails 170 | ._* 171 | 172 | # Files that might appear in the root of a volume 173 | .DocumentRevisions-V100 174 | .fseventsd 175 | .Spotlight-V100 176 | .TemporaryItems 177 | .Trashes 178 | .VolumeIcon.icns 179 | .com.apple.timemachine.donotpresent 180 | 181 | # Directories potentially created on remote AFP share 182 | .AppleDB 183 | .AppleDesktop 184 | Network Trash Folder 185 | Temporary Items 186 | .apdisk 187 | 188 | ### Windows ### 189 | # Windows thumbnail cache files 190 | Thumbs.db 191 | Thumbs.db:encryptable 192 | ehthumbs.db 193 | ehthumbs_vista.db 194 | 195 | # Dump file 196 | *.stackdump 197 | 198 | # Folder config file 199 | [Dd]esktop.ini 200 | 201 | # Recycle Bin used on file shares 202 | $RECYCLE.BIN/ 203 | 204 | # Windows Installer files 205 | *.cab 206 | *.msi 207 | *.msix 208 | *.msm 209 | *.msp 210 | 211 | # Windows shortcuts 212 | *.lnk 213 | 214 | # IDEA 215 | .idea 216 | 217 | # End of https://www.toptal.com/developers/gitignore/api/node,osx,windows,linux -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2023 OramaSearch Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Highlight 2 | 3 | [![Test CI](https://github.com/oramasearch/highlight/actions/workflows/test.yml/badge.svg)](https://github.com/oramasearch/highlight/actions/workflows/test.yml) 4 | 5 | Orama Highlight allows you to easily highlight substrings in a given input. 6 | 7 | # Installation 8 | 9 | ```bash 10 | npm i @orama/highlight 11 | bun i @orama/highlight 12 | ``` 13 | 14 | # Usage 15 | 16 | ```js 17 | import { Highlight } from '@orama/highlight' 18 | 19 | const inputString = 'The quick brown fox jumps over the lazy dog' 20 | const toHighlight = 'brown fox jump' 21 | 22 | const highlighter = new Highlight() 23 | const highlighted = highlighter.highlight(inputString, toHighlight) 24 | 25 | console.log(highlighted.positions) 26 | // [ 27 | // { 28 | // start: 10, 29 | // end: 14 30 | // }, { 31 | // start: 16, 32 | // end: 18 33 | // }, { 34 | // start: 20, 35 | // end: 23 36 | // } 37 | // ] 38 | 39 | console.log(highlighted.HTML) 40 | // "The quick brown fox jumps over the lazy dog" 41 | 42 | console.log(highlighted.trim(10)) 43 | // "...uick brown..." 44 | ``` 45 | 46 | You can always customize the library behavior by passing some options to the class constructor: 47 | 48 | ```js 49 | const highlighted = new Highlight({ 50 | caseSensitive: true, // Only highlight words that respect the second parameter's casing. Default is false 51 | wholeWords: true, // Only highlight entire words, no prefixes 52 | HTMLTag: 'div', // Default is "mark" 53 | CSSClass: 'my-custom-class' // default is 'orama-highlight' 54 | }) 55 | ``` 56 | 57 | # License 58 | [Apache 2.0](/LICENSE.md) -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oramasearch/highlight/c59ed9eaf4d2f1fd5a537df2b7f7938bd45a49e2/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | coverage = true -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orama/highlight", 3 | "version": "0.1.9", 4 | "description": "Highlight any text in any JavaScript lib (browser, server, React, Vue, you name it!)", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "files": [ 8 | "dist" 9 | ], 10 | "exports": { 11 | ".": { 12 | "require": "./dist/index.cjs", 13 | "import": "./dist/index.js", 14 | "types": "./dist/index.d.ts", 15 | "browser": "./dist/index.global.js" 16 | } 17 | }, 18 | "scripts": { 19 | "test": "bun test", 20 | "lint": "ts-standard --fix ./src/**/*.ts", 21 | "build": "npm run build:lib", 22 | "build:lib": "tsup --config tsup.lib.js" 23 | }, 24 | "keywords": [ 25 | "full-text search", 26 | "search", 27 | "highlight" 28 | ], 29 | "author": { 30 | "name": "Michele Riva", 31 | "email": "michele.riva@oramasearch.com" 32 | }, 33 | "license": "Apache-2.0", 34 | "devDependencies": { 35 | "@types/react": "^18.2.25", 36 | "@types/sinon": "^10.0.20", 37 | "bun-types": "^1.0.4-canary.20231004T140131", 38 | "react": "^18.2.0", 39 | "sinon": "^17.0.0", 40 | "ts-standard": "^12.0.2", 41 | "tsup": "^7.2.0", 42 | "typescript": "^5.2.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, it } from "bun:test"; 2 | import assert from "node:assert"; 3 | import sinon from "sinon"; 4 | import { Highlight, highlightStrategy } from "./index.js"; 5 | 6 | describe("default configuration", () => { 7 | it("should correctly highlight a text", () => { 8 | const text1 = "The quick brown fox jumps over the lazy dog"; 9 | const searchTerm1 = "fox"; 10 | const expectedResult1 = 11 | 'The quick brown fox jumps over the lazy dog'; 12 | 13 | const text2 = 14 | "Yesterday all my troubles seemed so far away, now it looks as though they're here to stay oh, I believe in yesterday"; 15 | const searchTerm2 = "yesterday I was in trouble"; 16 | const expectedResult2 = 17 | 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday'; 18 | 19 | const highlighter = new Highlight(); 20 | 21 | assert.strictEqual( 22 | highlighter.highlight(text1, searchTerm1).HTML, 23 | expectedResult1 24 | ); 25 | assert.strictEqual( 26 | highlighter.highlight(text2, searchTerm2).HTML, 27 | expectedResult2 28 | ); 29 | }); 30 | 31 | it("should return the correct positions", () => { 32 | const text = "The quick brown fox jumps over the lazy dog"; 33 | const searchTerm = "fox"; 34 | const expectedPositions = [{ start: 16, end: 18 }]; 35 | 36 | const highlighter = new Highlight(); 37 | 38 | assert.deepStrictEqual( 39 | highlighter.highlight(text, searchTerm).positions, 40 | expectedPositions 41 | ); 42 | }); 43 | 44 | it("should return multiple positions", () => { 45 | const text = "The quick brown fox jumps over the lazy dog"; 46 | const searchTerm = "the"; 47 | const expectedPositions = [ 48 | { start: 0, end: 2 }, 49 | { start: 31, end: 33 }, 50 | ]; 51 | 52 | const highlighter = new Highlight(); 53 | 54 | assert.deepStrictEqual( 55 | highlighter.highlight(text, searchTerm).positions, 56 | expectedPositions 57 | ); 58 | }); 59 | }); 60 | 61 | describe("custom configuration", () => { 62 | it("should correctly highlight a text (case sensitive)", () => { 63 | const text1 = "The quick brown fox jumps over the lazy dog"; 64 | const searchTerm1 = "Fox"; 65 | const expectedResult1 = "The quick brown fox jumps over the lazy dog"; 66 | 67 | const text2 = 68 | "Yesterday all my troubles seemed so far away, now it looks as though they're here to stay oh, I believe in yesterday"; 69 | const searchTerm2 = "yesterday I was in trouble"; 70 | const expectedResult2 = 71 | 'Yesterday all my troubles seemed so far away, now it looks as though they\'re here to stay oh, I believe in yesterday'; 72 | 73 | const highlighter = new Highlight({ caseSensitive: true }); 74 | 75 | assert.strictEqual( 76 | highlighter.highlight(text1, searchTerm1).HTML, 77 | expectedResult1 78 | ); 79 | assert.strictEqual( 80 | highlighter.highlight(text2, searchTerm2).HTML, 81 | expectedResult2 82 | ); 83 | }); 84 | 85 | it("should correctly set a custom CSS class", () => { 86 | const text = "The quick brown fox jumps over the lazy dog"; 87 | const searchTerm = "fox"; 88 | const expectedResult = 89 | 'The quick brown fox jumps over the lazy dog'; 90 | 91 | const highlighter = new Highlight({ CSSClass: "custom-class" }); 92 | 93 | assert.strictEqual( 94 | highlighter.highlight(text, searchTerm).HTML, 95 | expectedResult 96 | ); 97 | }); 98 | 99 | it("should correctly use a custom HTML tag", () => { 100 | const text = "The quick brown fox jumps over the lazy dog"; 101 | const searchTerm = "fox"; 102 | const expectedResult = 103 | 'The quick brown
fox
jumps over the lazy dog'; 104 | 105 | const highlighter = new Highlight({ HTMLTag: "div" }); 106 | 107 | assert.strictEqual( 108 | highlighter.highlight(text, searchTerm).HTML, 109 | expectedResult 110 | ); 111 | }); 112 | 113 | it("should correctly highlight whole word matches only", () => { 114 | const text = "The quick brown fox jumps over the lazy dog"; 115 | const searchTerm = "fox jump"; 116 | const expectedResult = 117 | 'The quick brown fox jumps over the lazy dog'; 118 | 119 | const highlighter = new Highlight({ strategy: highlightStrategy.WHOLE_WORD_MATCH }); 120 | 121 | assert.strictEqual( 122 | highlighter.highlight(text, searchTerm).HTML, 123 | expectedResult 124 | ); 125 | }); 126 | 127 | it("should correctly highlight whole words on partial matches only", () => { 128 | const text = "The quick brown fox jumps over the lazy dog"; 129 | const searchTerm = "fo umps ve"; 130 | const expectedResult = 131 | 'The quick brown fox jumps over the lazy dog'; 132 | 133 | const highlighter = new Highlight({ 134 | strategy: highlightStrategy.PARTIAL_MATCH_FULL_WORD, 135 | }); 136 | 137 | assert.strictEqual( 138 | highlighter.highlight(text, searchTerm).HTML, 139 | expectedResult 140 | ); 141 | }); 142 | 143 | it("should not highlight anything on 0 matches on a partial match, full word highlight strategy when the search term is nothing", () => { 144 | const text = "The quick brown fox jumps over the lazy dog"; 145 | const searchTerm = ""; 146 | const expectedResult = "The quick brown fox jumps over the lazy dog"; 147 | 148 | const highlighter = new Highlight({ 149 | strategy: highlightStrategy.PARTIAL_MATCH_FULL_WORD, 150 | }); 151 | 152 | assert.strictEqual( 153 | highlighter.highlight(text, searchTerm).HTML, 154 | expectedResult 155 | ); 156 | }); 157 | }); 158 | 159 | describe("highlight function - infinite loop protection", () => { 160 | let regexExecStub: sinon.SinonStub; 161 | 162 | beforeEach(() => { 163 | regexExecStub = sinon.stub(RegExp.prototype, "exec"); 164 | }); 165 | 166 | afterEach(() => { 167 | regexExecStub.restore(); 168 | }); 169 | 170 | it("should exit the loop if regex.lastIndex does not advance", () => { 171 | const text = "The quick brown fox jumps over the lazy dog"; 172 | const searchTerm = "fox"; 173 | 174 | regexExecStub.callsFake(function () { 175 | // @ts-expect-error 176 | this.lastIndex = 0; 177 | return null; 178 | }); 179 | 180 | const highlighter = new Highlight(); 181 | const result = highlighter.highlight(text, searchTerm); 182 | 183 | assert.strictEqual(result.HTML, text); 184 | 185 | assert(regexExecStub.called); 186 | }); 187 | }); 188 | 189 | describe("trim method", () => { 190 | it("should correctly trim the text", () => { 191 | const text = "The quick brown fox jumps over the lazy dog"; 192 | const searchTerm = "fox"; 193 | const highlighter = new Highlight(); 194 | 195 | assert.strictEqual( 196 | highlighter.highlight(text, searchTerm).trim(10), 197 | '...rown fox j...' 198 | ); 199 | assert.strictEqual( 200 | highlighter.highlight(text, searchTerm).trim(5), 201 | '...n fox...' 202 | ); 203 | assert.strictEqual( 204 | highlighter.highlight(text, "the").trim(5), 205 | 'The q...' 206 | ); 207 | assert.strictEqual( 208 | highlighter.highlight(text, "dog").trim(5), 209 | '...y dog' 210 | ); 211 | assert.strictEqual( 212 | highlighter.highlight(text, "dog").trim(5, false), 213 | 'y dog' 214 | ); 215 | assert.strictEqual( 216 | highlighter.highlight(text, "the").trim(5, false), 217 | 'The q' 218 | ); 219 | }); 220 | it("should correctly trim the text when no match is found", () => { 221 | const text = "The quick brown dog jumps over the lazy dog in a forrest"; 222 | const searchTerm = "fox"; 223 | const highlighter = new Highlight(); 224 | 225 | assert.strictEqual( 226 | highlighter.highlight(text, searchTerm).trim(10), 227 | "The quick ..." 228 | ); 229 | assert.strictEqual( 230 | highlighter.highlight(text, searchTerm).trim(10, false), 231 | "The quick " 232 | ); 233 | }); 234 | }); 235 | 236 | describe("special characters", () => { 237 | it("should correctly highlight a text with special characters", () => { 238 | const text = "C++ is a hell of a language"; 239 | const searchTerm = "C++"; 240 | const expectedResult = 241 | 'C++ is a hell of a language'; 242 | 243 | const highlighter = new Highlight(); 244 | 245 | assert.strictEqual( 246 | highlighter.highlight(text, searchTerm).HTML, 247 | expectedResult 248 | ); 249 | }); 250 | }); 251 | 252 | describe("null example", () => { 253 | // even though it is not expected we should make sure it won't break 254 | it("should not break when text is null", () => { 255 | const searchTerm = "C"; 256 | const highlighter = new Highlight(); 257 | 258 | // @ts-expect-error 259 | assert.strictEqual(highlighter.highlight(null, searchTerm).HTML, ""); 260 | }); 261 | it("should not break when text and search term is null", () => { 262 | const highlighter = new Highlight(); 263 | 264 | assert.strictEqual( 265 | // @ts-expect-error 266 | highlighter.highlight(null, null).HTML, 267 | "" 268 | ); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export const highlightStrategy = { 2 | WHOLE_WORD_MATCH: "wholeWordMatch", 3 | PARTIAL_MATCH: "partialMatch", 4 | PARTIAL_MATCH_FULL_WORD: "partialMatchFullWord", 5 | } as const; 6 | 7 | export type HighlightStrategy = 8 | (typeof highlightStrategy)[keyof typeof highlightStrategy]; 9 | 10 | export interface HighlightOptions { 11 | caseSensitive?: boolean; 12 | strategy?: HighlightStrategy; 13 | HTMLTag?: string; 14 | CSSClass?: string; 15 | } 16 | export type Position = { start: number; end: number }; 17 | 18 | type Positions = Position[]; 19 | 20 | const defaultOptions: Required = { 21 | caseSensitive: false, 22 | strategy: highlightStrategy.PARTIAL_MATCH, 23 | HTMLTag: "mark", 24 | CSSClass: "orama-highlight", 25 | }; 26 | 27 | export class Highlight { 28 | private readonly options: HighlightOptions; 29 | private _positions: Positions = []; 30 | private _HTML: string = ""; 31 | private _searchTerm: string = ""; 32 | private _originalText: string = ""; 33 | 34 | constructor(options: HighlightOptions = defaultOptions) { 35 | this.options = { ...defaultOptions, ...options }; 36 | } 37 | 38 | public highlight(text: string, searchTerm: string): Highlight { 39 | this._searchTerm = searchTerm ?? ""; 40 | this._originalText = text ?? ""; 41 | 42 | if (!this._searchTerm || !this._originalText) { 43 | this._positions = []; 44 | this._HTML = this._originalText; 45 | return this; 46 | } 47 | 48 | const HTMLTag = this.options.HTMLTag ?? defaultOptions.HTMLTag; 49 | const CSSClass = this.options.CSSClass ?? defaultOptions.CSSClass; 50 | 51 | const caseSensitive = 52 | this.options.caseSensitive ?? defaultOptions.caseSensitive; 53 | const strategy = this.options.strategy ?? defaultOptions.strategy; 54 | const regexFlags = caseSensitive ? "g" : "gi"; 55 | const searchTerms = this.escapeRegExp( 56 | caseSensitive ? this._searchTerm : this._searchTerm.toLowerCase() 57 | ) 58 | .trim() 59 | .split(/\s+/) 60 | .join("|"); 61 | 62 | let regex: RegExp; 63 | if (strategy === highlightStrategy.WHOLE_WORD_MATCH) { 64 | regex = new RegExp(`\\b${searchTerms}\\b`, regexFlags); 65 | } else if (strategy === highlightStrategy.PARTIAL_MATCH) { 66 | regex = new RegExp(searchTerms, regexFlags); 67 | } else if (strategy === highlightStrategy.PARTIAL_MATCH_FULL_WORD) { 68 | regex = new RegExp(`\\b[^\\s]*(${searchTerms})[^\\s]*\\b`, regexFlags); 69 | } else { 70 | throw new Error("Invalid highlighter strategy"); 71 | } 72 | 73 | const positions: Array<{ start: number; end: number }> = []; 74 | const highlightedParts: string[] = []; 75 | 76 | let match; 77 | let lastEnd = 0; 78 | let previousLastIndex = -1; 79 | 80 | while ((match = regex.exec(this._originalText)) !== null) { 81 | if (regex.lastIndex === previousLastIndex) { 82 | break; 83 | } 84 | previousLastIndex = regex.lastIndex; 85 | 86 | const start = match.index; 87 | const end = start + match[0].length - 1; 88 | 89 | positions.push({ start, end }); 90 | 91 | highlightedParts.push(this._originalText.slice(lastEnd, start)); 92 | highlightedParts.push( 93 | `<${HTMLTag} class="${CSSClass}">${match[0]}` 94 | ); 95 | 96 | lastEnd = end + 1; 97 | } 98 | 99 | highlightedParts.push(this._originalText.slice(lastEnd)); 100 | 101 | this._positions = positions; 102 | this._HTML = highlightedParts.join(""); 103 | 104 | return this; 105 | } 106 | 107 | public trim(trimLength: number, ellipsis: boolean = true): string { 108 | if (this._positions.length === 0) { 109 | return `${this._HTML.substring(0, trimLength)}${ellipsis ? `...` : ""}`; 110 | } 111 | 112 | if (this._originalText.length <= trimLength) { 113 | return this._HTML; 114 | } 115 | 116 | const firstMatch = this._positions[0].start; 117 | const start = Math.max(firstMatch - Math.floor(trimLength / 2), 0); 118 | const end = Math.min(start + trimLength, this._originalText.length); 119 | const trimmedContent = `${ 120 | start === 0 || !ellipsis ? "" : "..." 121 | }${this._originalText.slice(start, end)}${ 122 | end < this._originalText.length && ellipsis ? "..." : "" 123 | }`; 124 | 125 | this.highlight(trimmedContent, this._searchTerm); 126 | return this._HTML; 127 | } 128 | 129 | get positions(): Positions { 130 | return this._positions; 131 | } 132 | 133 | get HTML(): string { 134 | return this._HTML; 135 | } 136 | 137 | private escapeRegExp(string: string): string { 138 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "types": ["bun-types"], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsup.lib.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | const entry = new URL('src/index.ts', import.meta.url).pathname 4 | const outDir = new URL('dist', import.meta.url).pathname 5 | 6 | export default defineConfig({ 7 | entry: [entry], 8 | splitting: false, 9 | sourcemap: true, 10 | minify: true, 11 | format: ['cjs', 'esm', 'iife'], 12 | globalName: 'OramaHighlight', 13 | dts: true, 14 | clean: true, 15 | bundle: true, 16 | outDir 17 | }) 18 | --------------------------------------------------------------------------------