@playpilot/tpi 3.8.1 → 4.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "3.8.1",
3
+ "version": "4.0.0-beta.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
package/src/lib/api.ts CHANGED
@@ -105,6 +105,8 @@ export async function saveLinkInjections(linkInjections: LinkInjection[], pageTe
105
105
  sid: linkInjection.sid,
106
106
  title: linkInjection.title,
107
107
  sentence: linkInjection.sentence,
108
+ phrase_before: linkInjection.phrase_before,
109
+ phrase_after: linkInjection.phrase_after,
108
110
  playpilot_url: linkInjection.playpilot_url,
109
111
  after_article: !!linkInjection.after_article,
110
112
  after_article_style: linkInjection.after_article_style || null,
@@ -2,7 +2,8 @@ import { mount, unmount } from 'svelte'
2
2
  import TitleModal from '../routes/components/TitleModal.svelte'
3
3
  import TitlePopover from '../routes/components/TitlePopover.svelte'
4
4
  import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
5
- import { cleanPhrase, findTextNodeContaining, isNodeInLink, replaceStartingFrom } from './text'
5
+ import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom } from './text'
6
+ import { getLargestValueInArray } from './array'
6
7
  import type { LinkInjection, LinkInjectionTypes } from './types/injection'
7
8
  import { isHoldingSpecialKey } from './event'
8
9
  import { playFallbackViewTransition } from './viewTransition'
@@ -138,32 +139,67 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
138
139
  const linkNodeContainingText = findTextNodeContaining(injection.title, element)
139
140
  if (linkNodeContainingText && isNodeInLink(linkNodeContainingText)) {
140
141
  failedMessages[injection.key] = 'Given text is already inside of a link.'
142
+ continue
141
143
  }
142
-
143
- continue
144
144
  }
145
145
 
146
- // Create a wrapper in which the link will be placed. This wrapper exists as a parent for the popover
147
- // so that it is not directly inside of the link.
148
- const linkWrapperElement = document.createElement('span')
149
- linkWrapperElement.dataset.playpilotInjectionKey = injection.key
146
+ const { injectionElement, linkElement } = createLinkInjectionElement(injection)
147
+
148
+ let replacementIndex = -1
149
+ let hasBeenReplaced = false
150
+
151
+ // !! Option 1 - Simple replacements
152
+ // Check if there is only one occurance in the element, in which case the replacement is simple
153
+ const numberOfMatches = findNumberOfMatchesInString(cleanPhrase(element.innerText), cleanPhrase(injection.title))
154
+ if (numberOfMatches === 1) replacementIndex = element.innerHTML.indexOf(injection.title)
155
+
156
+ // !! Option 2 - Replace by phrase_before and phrase_after
157
+ // If multiple or no occurances were found, we use phrase_before and phrase_after to refine the search
158
+ if (replacementIndex === -1 && (injection.phrase_before || injection.phrase_after)) {
159
+ console.log(injection.phrase_before)
160
+ // The before and after phrase are combined to see if the sentence contains the match exactly.
161
+ // This is a fairly simple comparison that will fail on special characters, html tags, or inconsistencies
162
+ const fullPhrase = [injection.phrase_before, injection.title, injection.phrase_after].filter(Boolean).join(' ')
163
+
164
+ replacementIndex = element.innerHTML.indexOf(fullPhrase)
165
+
166
+ // If we reach this point the match wasn't straight forward and we need to replace whatever is between phrase_before and phrase_after fully.
167
+ // We insert the html here separately from below, where it's done with replacementIndex because we need to capture all html
168
+ // that may have been inside of the match.
169
+ if (replacementIndex === -1) {
170
+ const match = findShortestMatchBetweenPhrases(element.innerHTML, injection.title, injection.phrase_before || '', injection.phrase_after || '')
171
+
172
+ if (match) {
173
+ const { leadingSpaces, trailingSpaces } = getNumberOfLeadingAndTrailingSpaces(match.match)
174
+
175
+ linkElement.innerHTML = match.match.trim()
176
+
177
+ const startIndex = match.index + leadingSpaces
178
+ const endIndex = match.index + match.match.length - trailingSpaces
179
+ element.innerHTML = replaceBetween(element.innerHTML, injectionElement.outerHTML, startIndex, endIndex)
150
180
 
151
- const linkElement = document.createElement('a')
152
- linkElement.innerText = injection.title
153
- linkElement.href = injection.playpilot_url
154
- linkElement.dataset.playpilotOriginalTitle = injection.title_details?.original_title
155
- linkElement.target = '_blank'
156
- linkElement.rel = 'noopener nofollow noreferrer'
181
+ replacementIndex = match.index
182
+ hasBeenReplaced = true
183
+ }
184
+ }
185
+ }
157
186
 
158
- linkWrapperElement.insertAdjacentElement('beforeend', linkElement)
187
+ // !! Option 3 - Replace by title only, taking previous injections into account
188
+ // If no occurances were found previously, we check the element from start to finish only checking for the title,
189
+ // without considering phrase_before and phrase_after
190
+ if (replacementIndex === -1 && nodeContainingText?.nodeValue) {
191
+ // Start searching for injection from either the value or the sentence. This prevents injecting into
192
+ // text in an element earlier than the sentence started. A element might contain many sentences, after all.
193
+ const valueIndex = element.innerHTML.indexOf(nodeContainingText.nodeValue)
194
+ const sentenceIndex = element.innerHTML.indexOf(injection.sentence)
159
195
 
160
- // Start searching for injection from either the value or the sentence. This prevents injecting into
161
- // text in an element earlier than the sentence started. A element might contain many sentences, after all.
162
- const valueIndex = element.innerHTML.indexOf(nodeContainingText.nodeValue)
163
- const sentenceIndex = element.innerHTML.indexOf(injection.sentence)
164
- const highestIndex = Math.max(valueIndex, sentenceIndex, 0)
196
+ replacementIndex = Math.max(valueIndex, sentenceIndex, 0)
197
+ }
165
198
 
166
- element.innerHTML = replaceStartingFrom(element.innerHTML, injection.title, linkWrapperElement.outerHTML, highestIndex)
199
+ if (replacementIndex === -1) continue
200
+ if (hasBeenReplaced) continue
201
+
202
+ element.innerHTML = replaceStartingFrom(element.innerHTML, injection.title, injectionElement.outerHTML, replacementIndex)
167
203
  }
168
204
 
169
205
  addLinkInjectionEventListeners(validInjections)
@@ -194,6 +230,23 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
194
230
  })
195
231
  }
196
232
 
233
+ function createLinkInjectionElement(injection: LinkInjection): { injectionElement: HTMLSpanElement, linkElement: HTMLAnchorElement } {
234
+ // Create a wrapper in which the link will be placed. This wrapper exists as a parent for the popover
235
+ // so that it is not directly inside of the link.
236
+ const injectionElement = document.createElement('span')
237
+ injectionElement.dataset.playpilotInjectionKey = injection.key
238
+
239
+ const linkElement = document.createElement('a')
240
+ linkElement.innerText = injection.title
241
+ linkElement.href = injection.playpilot_url
242
+ linkElement.target = '_blank'
243
+ linkElement.rel = 'noopener nofollow noreferrer'
244
+
245
+ injectionElement.insertAdjacentElement('beforeend', linkElement)
246
+
247
+ return { injectionElement, linkElement }
248
+ }
249
+
197
250
  /**
198
251
  * Add all used CSS variables to a data attribute. This data attribute is then used for selectors that for each
199
252
  * individual CSS variable. This is done this way so that CSS variables are only set when they are used.
@@ -363,7 +416,7 @@ function clearAfterArticlePlaylinks(): void {
363
416
  */
364
417
  export function clearLinkInjections(): void {
365
418
  const elements = document.querySelectorAll(keySelector)
366
- elements.forEach((element) => element.outerHTML = element.textContent || '')
419
+ elements.forEach((element) => clearLinkInjection(element.getAttribute(keyDataAttribute) || ''))
367
420
 
368
421
  clearAfterArticlePlaylinks()
369
422
  destroyLinkModal(false)
@@ -376,7 +429,10 @@ export function clearLinkInjections(): void {
376
429
  */
377
430
  export function clearLinkInjection(key: string): void {
378
431
  const element: HTMLAnchorElement | null = document.querySelector(`[${keyDataAttribute}="${key}"]`)
379
- if (element) element.outerHTML = element.textContent || ''
432
+ if (!element) return
433
+
434
+ const linkContent = element.querySelector('a')?.innerHTML
435
+ element.outerHTML = linkContent || ''
380
436
  }
381
437
 
382
438
  /**
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Get the index of the selection. The returned index will be exclusing any leading or trailing spaces. We contain it to actual phrases.
3
+ */
4
+ export function getIndexOfSelection(selection: Selection, node: Node | null = selection.anchorNode): { start: number, end: number } {
5
+ if (!selection.anchorNode || !node) return { start: -1, end: -1 }
6
+
7
+ const trimmedSelection = selection.toString().trim()
8
+ const preceedingSpaces = selection.toString().search(/\S/)
9
+
10
+ // If node is a text node we can simply use it's anchorOffset and focusOffset
11
+ if (selection.anchorNode === node && node.nodeType === Node.TEXT_NODE) {
12
+ const start = Math.min(selection.anchorOffset, selection.focusOffset) + preceedingSpaces
13
+
14
+ return { start, end: start + trimmedSelection.length }
15
+ }
16
+
17
+ // If node is an element (meaning the text alone was not enough for text, and we grabbed a parent with enough text content)
18
+ // Here we use the range to get the text before the selection and use it's length as the index.
19
+ const range = selection.getRangeAt(0)
20
+ const clonedRange = range.cloneRange()
21
+
22
+ clonedRange.selectNodeContents(node)
23
+ clonedRange.setEnd(range.startContainer, range.startOffset)
24
+
25
+ const fullTextBefore = clonedRange.toString()
26
+
27
+ const start = fullTextBefore.length + preceedingSpaces
28
+ const end = start + trimmedSelection.length
29
+
30
+ return { start, end }
31
+ }
package/src/lib/text.ts CHANGED
@@ -49,6 +49,10 @@ export function replaceStartingFrom(text: string, search: string, replacement: s
49
49
  return before + updatedAfter
50
50
  }
51
51
 
52
+ export function replaceBetween(text: string, replacement: string, startIndex: number, endIndex: number): string {
53
+ return text.substring(0, startIndex) + replacement + text.substring(endIndex)
54
+ }
55
+
52
56
  /**
53
57
  * Returns a string for most consistent string comparisons, decoding Html symbols and removing spaces.
54
58
  */
@@ -79,3 +83,98 @@ export function truncateAroundPhrase(sentence: string, phrase: string, maxLength
79
83
 
80
84
  return result
81
85
  }
86
+
87
+ /**
88
+ * Find phrases surrounding in a parentNode's text content around a given start and end index.
89
+ * For instance in "Some example text", if the startIndex and endIndex match the start and end of "example",
90
+ * the phrases "Some" and "text" will be returned.
91
+ */
92
+ export function findSurroundingPhrases(parentNode: Node, startIndex: number, endIndex: number): { before: string, after: string } {
93
+ const sentenceNode = parentNode.nodeType === Node.TEXT_NODE && parentNode.parentNode ? parentNode.parentElement : (parentNode as HTMLElement)
94
+ if (!sentenceNode || !parentNode.textContent) return { before: '', after: '' }
95
+
96
+ // Include the index of where the text is contained, as parentNode might start later within the sentenceNode
97
+ const parentNodeStartIndex = sentenceNode.innerText.indexOf(parentNode.textContent)
98
+
99
+ const stringBefore = reverseString(sentenceNode.innerText.slice(0, startIndex + parentNodeStartIndex))
100
+ const stringAfter = sentenceNode.innerText.slice(endIndex + parentNodeStartIndex)
101
+
102
+ const firstPhraseBefore = reverseString(getFirstNumberOfWordsInString(stringBefore, 2))
103
+ const firstPhraseAfter = getFirstNumberOfWordsInString(stringAfter, 2)
104
+
105
+ return { before: firstPhraseBefore, after: firstPhraseAfter }
106
+ }
107
+
108
+ export function reverseString(string: string): string {
109
+ return string.split('').reverse().join('')
110
+ }
111
+
112
+ /**
113
+ * Get the first x words in a string. Only returning full words, but can contain special characters like &, commas, period, etc.
114
+ */
115
+ export function getFirstNumberOfWordsInString(sentence: string, numberOfWords = 1): string {
116
+ const match = sentence.match(/[^\s]+/g)
117
+ return match ? match.slice(0, numberOfWords).join(' ').replace(/[.,!?;:]+$/u, '') : ''
118
+ }
119
+
120
+ export function findNumberOfMatchesInString(text: string, phrase: string): number {
121
+ let count = 0
122
+ let index = 0
123
+
124
+ while ((index = text.indexOf(phrase, index)) !== -1) {
125
+ count++
126
+ index += phrase.length
127
+ }
128
+
129
+ return count
130
+ }
131
+
132
+ /**
133
+ * Finds all matches between two phrases. For example "A sentence with a start and end phrase". Is "start" and "end" are given
134
+ * as the `start` and `end` properties, this will match on ` and `. This does not currently account for multiple occurences
135
+ * of the end phrase. For example "A sentence with a start and end phrase and another end" will only return 1 value, despite
136
+ * there being 2 end phrases with content between. While that might make sense, we don't currently need that as we always
137
+ * filter by the shortest length anyway.
138
+ */
139
+ export function findAllMatchesBetweenPhrases(text: string, start: string, end: string): { match: string, index: number }[] {
140
+ const matches: { match: string; index: number }[] = []
141
+
142
+ let currentIndex = 0
143
+ while (currentIndex <= text.length) {
144
+ const startIndex = start ? text.indexOf(start, currentIndex) : currentIndex
145
+
146
+ if (startIndex === -1 && start) break
147
+
148
+ const matchStart = start ? startIndex + start.length : startIndex
149
+ const endIndex = end ? text.indexOf(end, matchStart) : text.length
150
+
151
+ if (end && endIndex === -1) break
152
+
153
+ const match = text.slice(matchStart, endIndex)
154
+ matches.push({ match, index: matchStart })
155
+
156
+ currentIndex = end ? endIndex + end.length : text.length + 1
157
+ }
158
+
159
+ return matches
160
+ }
161
+
162
+ export function findShortestMatchBetweenPhrases(text: string, phrase: string, start: string, end: string): { match: string, index: number } | null {
163
+ const matches = findAllMatchesBetweenPhrases(text, start, end)
164
+ const matchesContainingPhrase = matches.filter(({ match }) => decodeHtmlEntities(match).includes(phrase))
165
+
166
+ if (!matchesContainingPhrase.length) return null
167
+
168
+ const shortestMatch = matchesContainingPhrase.reduce((shortest, current) =>
169
+ !shortest || current.match.length < shortest.match.length ? current : shortest,
170
+ )
171
+
172
+ return shortestMatch
173
+ }
174
+
175
+ export function getNumberOfLeadingAndTrailingSpaces(text: string): { leadingSpaces: number, trailingSpaces: number } {
176
+ return {
177
+ leadingSpaces: text.match(/^\s*/)?.[0].length || 0,
178
+ trailingSpaces: text.match(/\s*$/)?.[0].length || 0
179
+ }
180
+ }
@@ -7,6 +7,8 @@ export type LinkInjection = {
7
7
  playpilot_url: string
8
8
  key: string
9
9
  title_details?: TitleData
10
+ phrase_before?: string | null
11
+ phrase_after?: string | null
10
12
  inactive?: boolean
11
13
  failed?: boolean
12
14
  failed_message?: string
@@ -11,7 +11,8 @@
11
11
  import type { LinkInjection } from '$lib/types/injection'
12
12
  import type { TitleData } from '$lib/types/title'
13
13
  import { heading } from '$lib/actions/heading'
14
- import { cleanPhrase } from '$lib/text'
14
+ import { findSurroundingPhrases, cleanPhrase } from '$lib/text'
15
+ import { getIndexOfSelection } from '$lib/selection'
15
16
 
16
17
  interface Props {
17
18
  pageText: string
@@ -25,6 +26,8 @@
25
26
  let currentSelection = $state('')
26
27
  let selectionSentence = $state('')
27
28
  let selectedTitle: TitleData | null = $state(null)
29
+ let phraseAfter = $state('')
30
+ let phraseBefore = $state('')
28
31
  let error = $state('')
29
32
  let query = $state('')
30
33
 
@@ -49,13 +52,27 @@
49
52
  // Check if the selection contains more than 1 element with non-empty content.
50
53
  const fragment = selection.getRangeAt(0).cloneContents()
51
54
  const selectionNodesWithContent = Array.from(fragment.childNodes).filter(n => n.textContent?.trim())
52
- if (selectionNodesWithContent.length > 1) {
53
- error = 'Selection contains multiple items. Selection may not contain a mix of styled and non styled text. Please select the text more directly.'
55
+
56
+ if (selectionNodesWithContent.find(node => node.nodeName === 'A' || (node as HTMLElement).querySelector?.('a'))) {
57
+ error = 'Selection is or contains a link. Injections can not be created around already existing links.'
54
58
  return
55
59
  }
56
60
 
57
61
  query = currentSelection
58
- selectionSentence = findSentenceForSelection(selection, selectionText)
62
+
63
+ const { node: parentNode, sentence } = findSentenceForSelection(selection, selectionText)
64
+ if (!parentNode) return
65
+
66
+ selectionSentence = sentence
67
+
68
+ const { start, end } = getIndexOfSelection(selection, parentNode)
69
+
70
+ ;({ before: phraseBefore, after: phraseAfter } = findSurroundingPhrases(parentNode, start, end))
71
+
72
+ // Remove phrases if they were outside of the sentence. This can happen when a node contains more than
73
+ // one sentence and match was found before or after the sentence the selection was in.
74
+ if (!sentence.includes(phraseBefore)) phraseBefore = ''
75
+ if (!sentence.includes(phraseAfter)) phraseAfter = ''
59
76
 
60
77
  const nodeContent = selection.getRangeAt(0).commonAncestorContainer.textContent
61
78
  const documentTextContent = cleanPhrase(pageText)
@@ -67,7 +84,7 @@
67
84
  /**
68
85
  * Find the sentence that the given selected phrase is in. This is limited by the node that the text is in.
69
86
  */
70
- function findSentenceForSelection(selection: Selection, selectionText: string): string {
87
+ function findSentenceForSelection(selection: Selection, selectionText: string): { node: Node | null, sentence: string } {
71
88
  const range = selection.getRangeAt(0)
72
89
 
73
90
  // Get the node the text is in. If the content of the node is very short we use the parent node instead.
@@ -78,7 +95,7 @@
78
95
  node = node.parentNode
79
96
  }
80
97
 
81
- if (!node || !node.textContent) return ''
98
+ if (!node || !node.textContent) return { node: null, sentence: '' }
82
99
 
83
100
  const fullText = node.textContent
84
101
 
@@ -92,8 +109,9 @@
92
109
 
93
110
  const sentenceStart = before === -1 ? 0 : before + 1
94
111
  const sentenceEnd = after
112
+ const sentence = fullText.slice(sentenceStart, sentenceEnd + 1).trim()
95
113
 
96
- return fullText.slice(sentenceStart, sentenceEnd + 1).trim()
114
+ return { node, sentence }
97
115
  }
98
116
 
99
117
  /**
@@ -144,6 +162,8 @@
144
162
  sid: selectedTitle.sid,
145
163
  title: currentSelection,
146
164
  sentence: selectionSentence,
165
+ phrase_before: phraseBefore,
166
+ phrase_after: phraseAfter,
147
167
  playpilot_url: url,
148
168
  key: generateInjectionKey(selectedTitle.sid),
149
169
  title_details: selectedTitle,
@@ -10,7 +10,7 @@
10
10
  let { value = $bindable(), label = '', name = '', readonly = false, oninput = () => null }: Props = $props()
11
11
  </script>
12
12
 
13
- <input type="text" bind:value {name} aria-label={label} placeholder={label} {readonly} {oninput} />
13
+ <input type="text" bind:value {name} aria-label={label} placeholder={label} {readonly} {oninput} autocomplete="off" />
14
14
 
15
15
  <style lang="scss">
16
16
  input {
@@ -21,7 +21,7 @@
21
21
  let useBackgroundFallback = $state(false)
22
22
  </script>
23
23
 
24
- <div class="content" class:small class:compact data-playpilot-link-injections-title data-playpilot-original-title={title.original_title}>
24
+ <div class="content" class:small class:compact data-playpilot-link-injections-title>
25
25
  <div class="header">
26
26
  {#if !compact}
27
27
  <div class="top">
@@ -71,13 +71,9 @@ describe('linkInjection.js', () => {
71
71
 
72
72
  expect(links[0].innerText).toBe(linkInjections[0].title)
73
73
  expect(links[0].href).toBe(linkInjections[0].playpilot_url)
74
- /** @ts-ignore It is defined */
75
- expect(links[0].dataset.playpilotOriginalTitle).toBe(linkInjections[0].title_details.original_title)
76
74
 
77
75
  expect(links[1].innerText).toBe(linkInjections[1].title)
78
76
  expect(links[1].href).toBe(linkInjections[1].playpilot_url)
79
- /** @ts-ignore It is defined */
80
- expect(links[1].dataset.playpilotOriginalTitle).toBe(linkInjections[1].title_details.original_title)
81
77
  })
82
78
 
83
79
  it('Should ignore injections that are marked as inactive', () => {
@@ -618,11 +614,61 @@ describe('linkInjection.js', () => {
618
614
 
619
615
  expect(document.querySelectorAll('a')).toHaveLength(4)
620
616
  })
617
+
618
+ it('Should properly inject into titles that are split up by multiple elements when phrase_before and phrase_after are given', () => {
619
+ document.body.innerHTML = '<section>Some text with a <strong>phra</strong><strong>se</strong> in it</section>'
620
+
621
+ const elements = getLinkInjectionElements(/** @type {HTMLElement} */ (document.querySelector('section')))
622
+ const injection = generateInjection('Some text with a phrase in it', 'phrase')
623
+ injection.phrase_before = 'with a'
624
+ injection.phrase_after = 'in it'
625
+
626
+ injectLinksInDocument(elements, { aiInjections: [], manualInjections: [injection] })
627
+
628
+ expect(document.querySelector('a')?.innerText).toBe(injection.title)
629
+ })
630
+
631
+ it('Should properly inject into titles that are split up by multiple elements when only phrase_before is given', () => {
632
+ document.body.innerHTML = '<section>Some text with a <strong>phra</strong><strong>se</strong></section>'
633
+
634
+ const elements = getLinkInjectionElements(/** @type {HTMLElement} */ (document.querySelector('section')))
635
+ const injection = generateInjection('Some text with a phrase', 'phrase')
636
+ injection.phrase_before = 'with a'
637
+
638
+ injectLinksInDocument(elements, { aiInjections: [], manualInjections: [injection] })
639
+
640
+ expect(document.querySelector('a')?.innerText).toBe(injection.title)
641
+ })
642
+
643
+ it('Should properly inject into titles that are split up by multiple elements when only phrase_after is given', () => {
644
+ document.body.innerHTML = '<section><strong>phra</strong><strong>se</strong> in it</section>'
645
+
646
+ const elements = getLinkInjectionElements(/** @type {HTMLElement} */ (document.querySelector('section')))
647
+ const injection = generateInjection('phrase in it', 'phrase')
648
+ injection.phrase_after = 'in it'
649
+
650
+ injectLinksInDocument(elements, { aiInjections: [], manualInjections: [injection] })
651
+
652
+ expect(document.querySelector('a')?.innerText).toBe(injection.title)
653
+ })
654
+
655
+ it('Should properly inject into titles if phrase_before or phrase_after are nonsense, but title can be matched simply', () => {
656
+ document.body.innerHTML = '<section>Some text with a phrase in it</section>'
657
+
658
+ const elements = getLinkInjectionElements(/** @type {HTMLElement} */ (document.querySelector('section')))
659
+ const injection = generateInjection('Some text with a phrase in it', 'phrase')
660
+ injection.phrase_before = 'random'
661
+ injection.phrase_after = 'crap'
662
+
663
+ injectLinksInDocument(elements, { aiInjections: [], manualInjections: [injection] })
664
+
665
+ expect(document.querySelector('a')?.innerText).toBe(injection.title)
666
+ })
621
667
  })
622
668
 
623
669
  describe('clearLinkInjections', () => {
624
670
  it('Should remove injected links from the page', () => {
625
- document.body.innerHTML = '<p><a data-playpilot-injection-key>Some link</a></p>'
671
+ document.body.innerHTML = '<p><span data-playpilot-injection-key><a>Some link</a></span></p>'
626
672
  clearLinkInjections()
627
673
 
628
674
  expect(document.body.innerHTML).toBe('<p>Some link</p>')
@@ -638,17 +684,17 @@ describe('linkInjection.js', () => {
638
684
 
639
685
  describe('clearLinkInjection', () => {
640
686
  it('Should remove injected link from the page', () => {
641
- document.body.innerHTML = '<p><a data-playpilot-injection-key="a">Some link</a></p>'
687
+ document.body.innerHTML = '<p><span data-playpilot-injection-key="a"><a>Some link</a></span></p>'
642
688
  clearLinkInjection('a')
643
689
 
644
690
  expect(document.body.innerHTML).toBe('<p>Some link</p>')
645
691
  })
646
692
 
647
693
  it('Should keep other keys intact', () => {
648
- document.body.innerHTML = '<p><a data-playpilot-injection-key="b">Some link</a></p>'
694
+ document.body.innerHTML = '<p><span data-playpilot-injection-key="b"><a>Some link</a></span></p>'
649
695
  clearLinkInjection('a')
650
696
 
651
- expect(document.body.innerHTML).toBe('<p><a data-playpilot-injection-key="b">Some link</a></p>')
697
+ expect(document.body.innerHTML).toBe('<p><span data-playpilot-injection-key="b"><a>Some link</a></span></p>')
652
698
  })
653
699
  })
654
700