{"version":3,"file":"highlight-words.min.mjs","sources":["../src/clip.ts","../src/regexp.ts","../src/uid.ts","../src/index.ts"],"sourcesContent":["/* eslint complexity: [\"error\", { \"max\": 12 }] */\nimport type { HighlightWords } from '.';\n\nconst hasProp =\n  <T>(prop: string) =>\n  (obj: T) =>\n    obj !== null && typeof obj === 'object' && prop in obj;\nconst hasMatch = hasProp<HighlightWords.Chunk>('match');\n\nconst chunkExists = (\n  chunk: HighlightWords.Chunk | undefined\n): chunk is HighlightWords.Chunk => typeof chunk !== 'undefined';\n\n/**\n * This provides context around a chunk's text, based on the next and previous chunks.\n * e.g. If we have the string \"The quick brown fox jumped over the lazy dog\",\n * and the search term \"fox jumped\", with a padding of 2, we want to have the end result be:\n * \"... quick brown fox jumped over the ...\"\n * The search term, \"fox jumped\" is left as is, and the left and right chunks, instead of having\n * the text in full, will be clipped.\n */\nexport default function clip({\n  curr,\n  next,\n  prev,\n  clipBy = 3\n}: HighlightWords.Clip): string {\n  const words = curr.text.split(' ');\n  const len = words.length;\n\n  // If the current is a match, we leave it alone\n  // Or if the clipBy is greater than or equal to the length of the words, there's nothing to clip\n  if (curr.match || clipBy >= len) {\n    return curr.text;\n  }\n\n  // If we have a clipBy greater than the length of the words in the current match,\n  // it means we can clip the words in the current chunk\n  const ellipsis = '...';\n\n  if (\n    chunkExists(next) &&\n    chunkExists(prev) &&\n    hasMatch(prev) &&\n    hasMatch(next)\n  ) {\n    // Both the previous and the next chunks are a match\n    // Let's check if we have enough words to clip by on both sides\n    if (len > clipBy * 2) {\n      return [\n        ...words.slice(0, clipBy),\n        ellipsis,\n        ...words.slice(-clipBy)\n      ].join(' ');\n    }\n\n    return curr.text;\n  }\n\n  // We start to check the next and previous matches in order to\n  // properly position the elipsis\n  if (chunkExists(next) && hasMatch(next)) {\n    // The chunk right after this one is a match\n    // So we need the elipsis at the start of the returned text\n    // so that it sticks correctly to the next (match)'s text\n    return [ellipsis, ...words.slice(-clipBy)].join(' ');\n  }\n\n  if (chunkExists(prev) && hasMatch(prev)) {\n    // The chunk right before this one is a match\n    // So we need the elipsis at the end of the                                 returned text\n    // so that it sticks correctly to the previous (match)'s text\n    return [...words.slice(0, clipBy), ellipsis].join(' ');\n  }\n\n  // If we made it this far, just return the text\n  return curr.text;\n}\n","import type { HighlightWords } from '.';\n\n// We need escape certain characters before creating the RegExp\n// https://github.com/sindresorhus/escape-string-regexp\nconst escapeRegexp = (term: string): string =>\n  term.replace(/[|\\\\{}()[\\]^$+*?.-]/g, (char: string) => `\\\\${char}`);\n\nconst termsToRegExpString = (terms: string): string =>\n  terms\n    .replace(/\\s{2,}/g, ' ')\n    .split(' ')\n    .join('|');\n\nconst regexpQuery = ({\n  terms,\n  matchExactly = false\n}: Readonly<HighlightWords.Query>): string => {\n  if (typeof terms !== 'string') {\n    throw new TypeError('Expected a string');\n  }\n\n  const escapedTerms = escapeRegexp(terms.trim());\n  return `(${matchExactly ? escapedTerms : termsToRegExpString(escapedTerms)})`;\n};\n\nconst buildRegexp = ({\n  terms,\n  matchExactly = false\n}: Readonly<HighlightWords.Query>): RegExp => {\n  try {\n    const fromString = /^([/~@;%#'])(.*?)\\1([gimsuy]*)$/.exec(terms);\n    if (fromString) {\n      return new RegExp(fromString[2], fromString[3]);\n    }\n\n    return new RegExp(regexpQuery({ terms, matchExactly }), 'ig');\n  } catch {\n    throw new TypeError('Expected terms to be either a string or a RegExp!');\n  }\n};\n\nexport { regexpQuery };\n\nexport default buildRegexp;\n","// https://github.com/lukeed/uid/blob/master/src/index.js\n/* eslint no-bitwise: 'off' */\n/* eslint no-plusplus: 'off' */\n/* eslint @typescript-eslint/strict-boolean-expressions: 'off' */\nlet IDX = 36;\nlet HEX = '';\nwhile (IDX--) {\n  HEX += IDX.toString(36);\n}\n\nexport default function uid(len = 11): string {\n  let str = '';\n  let num = len;\n  while (num--) {\n    str += HEX[(Math.random() * 36) | 0];\n  }\n  return str;\n}\n","import clip from './clip';\nimport buildRegexp from './regexp';\nimport uid from './uid';\n\ndeclare namespace HighlightWords {\n  export interface Chunk {\n    key: string;\n    text: string;\n    match: boolean;\n  }\n\n  export interface Options {\n    text: string;\n    query: string;\n    clipBy?: number;\n    matchExactly?: boolean;\n  }\n\n  export interface Clip {\n    curr: Chunk;\n    next?: Chunk;\n    prev?: Chunk;\n    clipBy?: number;\n  }\n\n  export interface Query {\n    terms: string;\n    matchExactly?: boolean;\n  }\n}\n\nexport { HighlightWords };\n/* eslint-enable import/no-unused-modules */\n\nconst hasLength = (str: string): boolean => str.length > 0;\n\n/**\n * Split a text into chunks denoting which are a match and which are not based on a user search term.\n * @param text          String  The text to split.\n * @param query         String  The query to split by. This can contain multiple words.\n * @param clipBy        Number  Clip the non-matches by a certain number of words to provide context around the matches.\n * @param matchExactly  Boolean If we have multiple words in the query, we will match any of the words if exact is false. For example, searching for \"brown fox\" in \"the brown cute fox\" will yield both \"brown\" and \"fox\" as matches. While if exact is true, the same search will return no results.\n */\nconst highlightWords = ({\n  text,\n  query,\n  clipBy,\n  matchExactly = false\n}: Readonly<HighlightWords.Options>): HighlightWords.Chunk[] => {\n  // Let's make sure that the user cannot pass in just a bunch of spaces\n  const safeQuery = typeof query === 'string' ? query.trim() : query;\n\n  if (safeQuery === '') {\n    return [\n      {\n        key: uid(),\n        text,\n        match: false\n      }\n    ];\n  }\n\n  const searchRegexp = buildRegexp({ terms: query, matchExactly });\n\n  type ReadonlyChunk = Readonly<HighlightWords.Chunk>;\n\n  return text\n    .split(searchRegexp) // Split the entire thing into an array of matches and non-matches\n    .filter(hasLength) // Filter any matches that have the text with length of 0\n    .map((str) => ({\n      // Compose the object for a match\n      key: uid(),\n      text: str,\n      match: matchExactly\n        ? str.toLowerCase() === safeQuery.toLowerCase()\n        : searchRegexp.test(str)\n    }))\n    .map((chunk: ReadonlyChunk, index, chunks: readonly ReadonlyChunk[]) => ({\n      // For each chunk, clip the text if needed\n      ...chunk, // All the props first\n      ...(typeof clipBy === 'number' && {\n        // We only overwrite the text if there is a clip\n        text: clip({\n          curr: chunk, // We need the current chunk\n          ...(index < chunks.length - 1 && { next: chunks[index + 1] }), // If this wasn't the last chunk, set the next chunk\n          ...(index > 0 && { prev: chunks[index - 1] }), // If this wasn't the first chunk, set the previous chunk\n          clipBy\n        })\n      })\n    }));\n};\n\nexport default highlightWords;\n"],"names":["hasProp","prop","obj","hasMatch","chunkExists","chunk","clip","curr","next","prev","clipBy","words","len","ellipsis","escapeRegexp","term","char","termsToRegExpString","terms","regexpQuery","matchExactly","escapedTerms","buildRegexp","fromString","IDX","HEX","uid","str","num","hasLength","highlightWords","text","query","safeQuery","searchRegexp","index","chunks"],"mappings":"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"}