@playpilot/tpi 5.25.0 → 5.26.0

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": "5.25.0",
3
+ "version": "5.26.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -317,14 +317,20 @@ function createLinkInjectionElement(injection: LinkInjection): { injectionElemen
317
317
  * when things break we'd fix it instead.
318
318
  */
319
319
  function replaceIfSafeInjection(originalHtml: string, phrase: string, sentenceElement: HTMLElement, injectionElement: HTMLElement, replacementIndex: number): boolean {
320
+ const getNumberOfEmptyLinks = (element: HTMLElement) => Array.from(element.querySelectorAll('a')).filter(a => !a.innerText).length
321
+
320
322
  const dummyElement = document.createElement('div')
321
323
  dummyElement.innerHTML = originalHtml
322
324
 
325
+ const originalNumberOfEmptyLinks = getNumberOfEmptyLinks(dummyElement)
323
326
  const originalText = dummyElement.innerText
324
327
  dummyElement.innerHTML = replaceStartingFrom(originalHtml, phrase, injectionElement.outerHTML, replacementIndex)
325
328
 
326
329
  // If the text has changed at all, something probably went wrong as the new text is supposed to be the same as the old.
327
330
  if (Math.abs(dummyElement.innerText.length - originalText.length) > 1) return false
331
+ // One of our links pushed out an existing link on the page. When this happens the original link is emptied (because a link inserted into a link, and that is invalid)
332
+ // Not sure of the exact circumstances this might happen in, but it has happened on DigitalSpy
333
+ if (originalNumberOfEmptyLinks != getNumberOfEmptyLinks(dummyElement)) return false
328
334
 
329
335
  sentenceElement.innerHTML = dummyElement.innerHTML
330
336
 
package/src/lib/meta.ts CHANGED
@@ -34,7 +34,7 @@ export function getPageModifiedTime(parent: HTMLElement): string | null {
34
34
  parent.querySelector('[itemprop="dateModified"]') ||
35
35
  document.querySelector('[itemprop="dateModified"]') || null) as HTMLElement | null
36
36
 
37
- const datetime = element?.getAttribute('content') || element?.getAttribute('datetime') || element?.innerText
37
+ const datetime = getSchemaJson().dateModified || element?.getAttribute('content') || element?.getAttribute('datetime') || element?.innerText
38
38
 
39
39
  if (!datetime) return null
40
40
 
@@ -55,7 +55,7 @@ export function getPagePublishedTime(parent: HTMLElement): string | null {
55
55
  parent.querySelector('[datetime]') ||
56
56
  document.querySelector('[datetime]') || null) as HTMLElement | null
57
57
 
58
- const datetime = element?.getAttribute('content') || element?.getAttribute('datetime') || element?.innerText
58
+ const datetime = getSchemaJson().datePublished || element?.getAttribute('content') || element?.getAttribute('datetime') || element?.innerText
59
59
 
60
60
  if (!datetime) return null
61
61
 
@@ -72,3 +72,15 @@ export function getDatetime(datetime: string): string | null {
72
72
  return null
73
73
  }
74
74
  }
75
+
76
+ export function getSchemaJson(): Record<string, any> {
77
+ const schemaElement = document.querySelector('[type="application/ld+json"]')
78
+
79
+ if (!schemaElement) return {}
80
+
81
+ try {
82
+ return JSON.parse(schemaElement.textContent)
83
+ } catch(e) {
84
+ return {}
85
+ }
86
+ }
@@ -15,7 +15,11 @@ const baseUrl = 'https://insights.playpilot.net'
15
15
  */
16
16
  export async function track(event: string, title: TitleData | null = null, payload: Record<string, any> = {}): Promise<void> {
17
17
  if (isCrawler()) return
18
- if (!hasConsentedTo('tracking')) return
18
+
19
+ if (!hasConsentedTo('tracking')) {
20
+ queueTrackingEvent(event, title, payload)
21
+ return
22
+ }
19
23
 
20
24
  const headers = new Headers({ 'Content-Type': 'application/json' })
21
25
 
@@ -68,3 +72,24 @@ export function setTrackingSids({ organizationSid = null, domainSid = null }: {
68
72
  window.PlayPilotLinkInjections.organization_sid = organizationSid || null
69
73
  window.PlayPilotLinkInjections.domain_sid = domainSid || null
70
74
  }
75
+
76
+ export function queueTrackingEvent(event: string, title: TitleData | null = null, payload: Record<string, any> = {}): void {
77
+ window.PlayPilotLinkInjections.queued_tracking_events ||= []
78
+ window.PlayPilotLinkInjections.queued_tracking_events.push({ event, title, payload })
79
+ }
80
+
81
+ /**
82
+ * If a user has consented, fire all tracking events that were previously fired before the user gave consent.
83
+ * If a user still has not given consent nothing will happen. This function will re-fire if the user were
84
+ * to give consent later still.
85
+ * All events are removed from the queue if the user has consented.
86
+ */
87
+ export function fireQueuedTrackingEvents(): void {
88
+ if (!hasConsentedTo('tracking')) return
89
+
90
+ for (const { event, title, payload } of window.PlayPilotLinkInjections.queued_tracking_events || []) {
91
+ track(event, title, payload)
92
+ }
93
+
94
+ window.PlayPilotLinkInjections.queued_tracking_events = []
95
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Campaign } from './campaign'
2
2
  import type { ConsentOptions } from './consent'
3
3
  import type { LinkInjection } from './injection'
4
+ import type { TitleData } from './title'
4
5
 
5
6
  export type ScriptConfig = {
6
7
  // The API token to authenticate with the backend.
@@ -23,6 +24,8 @@ export type ScriptConfig = {
23
24
  last_successful_fetch?: LinkInjectionResponse | null
24
25
  // Lists all tracked events through the `track()` function.
25
26
  tracked_events?: { event: string, payload: Record<string, any> }[]
27
+ // Queued tracking events that were fired before consent was given. These might be fired later if the user consents.
28
+ queued_tracking_events?: { event: string, title: TitleData | null, payload: Record<strong, any> }[]
26
29
  // Lists all split test identifiers as created by each running split test. Identifiers are only created when a split test is fired.
27
30
  split_test_identifiers?: Record<string, number>
28
31
  // All link injections as returned from external-pages, with AI and manual injections merged.
package/src/main.ts CHANGED
@@ -16,6 +16,7 @@ window.PlayPilotLinkInjections = {
16
16
  domain_sid: null,
17
17
  last_successful_fetch: null,
18
18
  tracked_events: [],
19
+ queued_tracking_events: [],
19
20
  split_test_identifiers: {},
20
21
  evaluated_link_injections: [],
21
22
  ads: [],
@@ -109,6 +110,10 @@ window.PlayPilotLinkInjections = {
109
110
  console.log(this.tracked_events)
110
111
  console.groupEnd()
111
112
 
113
+ console.groupCollapsed('Queued tracking events')
114
+ console.log(this.queued_tracking_events)
115
+ console.groupEnd()
116
+
112
117
  console.groupCollapsed('Split tests')
113
118
  console.log(this.split_test_identifiers)
114
119
  console.groupEnd()
@@ -2,7 +2,7 @@
2
2
  import { onDestroy } from 'svelte'
3
3
  import { pollLinkInjections } from '$lib/api/externalPages'
4
4
  import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, getPageText, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/injection'
5
- import { setTrackingSids, track } from '$lib/tracking'
5
+ import { fireQueuedTrackingEvents, setTrackingSids, track } from '$lib/tracking'
6
6
  import { getFullUrlPath } from '$lib/url'
7
7
  import { isCrawler } from '$lib/crawler'
8
8
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
@@ -33,6 +33,9 @@
33
33
 
34
34
  const pageText = $derived(getPageText(elements))
35
35
 
36
+ // Store initializing promise for later, we'll need that to be awaited after the user has given or refused consent.
37
+ const initializePromise = initialize()
38
+
36
39
  // Rerender link injections when linkInjections change. This is only relevant for editiorial mode.
37
40
  $effect(() => {
38
41
  if (isEditorialMode && !loading) rerender()
@@ -42,11 +45,13 @@
42
45
 
43
46
  // This function is called when a user has properly consented via tcfapi or if no consent is required.
44
47
  // Both of these options go through the Consent component.
45
- async function start() {
46
- await initialize()
47
-
48
+ async function afterConsent() {
48
49
  if (isCrawler()) return
49
50
 
51
+ // Make sure the initializing is done, otherwise injections are not set properly
52
+ await initializePromise
53
+
54
+ fireQueuedTrackingEvents()
50
55
  track(TrackingEvent.ArticlePageView)
51
56
 
52
57
  if (aiInjections.length || manualInjections.length) window.PlayPilotLinkInjections.ads = await fetchAds()
@@ -225,7 +230,7 @@
225
230
  <TrackingPixels pixels={response.pixels} />
226
231
  {/if}
227
232
 
228
- <Consent onchange={start} />
233
+ <Consent onchange={afterConsent} />
229
234
 
230
235
  <style lang="scss">
231
236
  @import url('$lib/scss/global.scss');
@@ -703,6 +703,17 @@ describe('linkInjection.js', () => {
703
703
  expect(document.querySelector('a')).not.toBeTruthy()
704
704
  })
705
705
 
706
+ it('Should not inject injections into already existing links when the page contains multiple sentences of the same text where one has an existing link and the other does not.', () => {
707
+ document.body.innerHTML = '<p>This is a <a href="#">phrase</a>.</p> <p>This is a phrase.</p>'
708
+
709
+ const elements = Array.from(document.querySelectorAll('p'))
710
+ const injection = generateInjection('This is a phrase.', 'phrase')
711
+
712
+ injectLinksInDocument(elements, { aiInjections: [injection, injection], manualInjections: [] })
713
+
714
+ expect(document.querySelectorAll('a')).toHaveLength(1)
715
+ })
716
+
706
717
  it('Should not mount popover if user uses touch', async () => {
707
718
  mockMatchMedia(true)
708
719
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
- import { getPageHeading, getPageModifiedTime, getPagePublishedTime } from '$lib/meta'
2
+ import { getPageHeading, getPageModifiedTime, getPagePublishedTime, getSchemaJson } from '$lib/meta'
3
3
 
4
4
  describe('meta.js', () => {
5
5
  beforeEach(() => {
@@ -89,6 +89,22 @@ describe('meta.js', () => {
89
89
  const parent = /** @type {HTMLElement} */ (document.querySelector('section'))
90
90
  expect(getPageModifiedTime(parent)).toBe('2025-01-01T00:00:00.000Z')
91
91
  })
92
+
93
+ it('Should use schema script before other elements', () => {
94
+ document.head.innerHTML = '<meta content="2025-01-01" property="article:modified_time">'
95
+ document.body.innerHTML = `
96
+ <div itemprop="dateModified">2025-01-02</div>
97
+ <section>
98
+ <div itemprop="dateModified">2025-01-03</div>
99
+ </section>
100
+ <script type="application/ld+json">
101
+ {"@context":"https://schema.org","dateModified":"2025-09-24T07:22:07.000Z"}
102
+ </script>
103
+ `
104
+
105
+ const parent = /** @type {HTMLElement} */ (document.querySelector('section'))
106
+ expect(getPageModifiedTime(parent)).toBe('2025-09-24T07:22:07.000Z')
107
+ })
92
108
  })
93
109
 
94
110
  describe('getPagePublishedTime', () => {
@@ -197,5 +213,53 @@ describe('meta.js', () => {
197
213
  const parent = /** @type {HTMLElement} */ (document.querySelector('section'))
198
214
  expect(getPagePublishedTime(parent)).toBe('2025-01-01T00:00:00.000Z')
199
215
  })
216
+
217
+ it('Should use schema script before other elements', () => {
218
+ document.head.innerHTML = '<meta content="2025-01-01" property="article:published_time">'
219
+ document.body.innerHTML = `
220
+ <time>2025-01-02</time>
221
+ <div itemprop="datePublished">2025-01-03</div>
222
+ <section>
223
+ <time>2025-01-04</time>
224
+ </section>
225
+ <script type="application/ld+json">
226
+ {"@context":"https://schema.org","datePublished":"2025-09-24T07:22:07.000Z"}
227
+ </script>
228
+ `
229
+
230
+ const parent = /** @type {HTMLElement} */ (document.querySelector('section'))
231
+ expect(getPagePublishedTime(parent)).toBe('2025-09-24T07:22:07.000Z')
232
+ })
233
+ })
234
+
235
+ describe('getSchemaJson', () => {
236
+ it('Should return empty object if no schema element is present', () => {
237
+ document.body.innerHTML = '<script></script>'
238
+
239
+ expect(getSchemaJson()).toEqual({})
240
+ })
241
+
242
+ it('Should return schema relevant object', () => {
243
+ document.body.innerHTML = '<script type="application/ld+json">{ "key": "value" }</script>'
244
+
245
+ expect(getSchemaJson()).toEqual({ key: 'value' })
246
+ })
247
+
248
+ it('Should return empty object when json object is invalid', () => {
249
+ document.body.innerHTML = '<script type="application/ld+json"></script>'
250
+ expect(getSchemaJson()).toEqual({})
251
+
252
+ document.body.innerHTML = '<script type="application/ld+json">{}</script>'
253
+ expect(getSchemaJson()).toEqual({})
254
+
255
+ document.body.innerHTML = '<script type="application/ld+json">{ key: "value" }</script>'
256
+ expect(getSchemaJson()).toEqual({})
257
+
258
+ document.body.innerHTML = '<script type="application/ld+json">{ "key": "value" }}</script>'
259
+ expect(getSchemaJson()).toEqual({})
260
+
261
+ document.body.innerHTML = '<script type="application/ld+json">{ "key": "Some "unescaped" value" }</script>'
262
+ expect(getSchemaJson()).toEqual({})
263
+ })
200
264
  })
201
265
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
 
3
- import { setTrackingSids, track } from '$lib/tracking'
3
+ import { fireQueuedTrackingEvents, queueTrackingEvent, setTrackingSids, track } from '$lib/tracking'
4
4
  import { title } from '$lib/fakeData'
5
5
  import { getFullUrlPath } from '$lib/url'
6
6
  import { fakeFetch } from '../helpers'
@@ -161,12 +161,13 @@ describe('$lib/tracking', () => {
161
161
  )
162
162
  })
163
163
 
164
- it('Should not track if user did not consent to tracking', () => {
164
+ it('Should not track if user did not consent to tracking, instead adding it to a queue', () => {
165
165
  vi.mocked(hasConsentedTo).mockImplementation(() => false)
166
166
 
167
167
  track('Some event')
168
168
 
169
169
  expect(global.fetch).not.toHaveBeenCalled()
170
+ expect(window.PlayPilotLinkInjections.queued_tracking_events).toHaveLength(1)
170
171
  })
171
172
 
172
173
  it('Should not track if crawler', () => {
@@ -204,4 +205,42 @@ describe('$lib/tracking', () => {
204
205
  expect(window.PlayPilotLinkInjections.organization_sid).toBe(null)
205
206
  })
206
207
  })
208
+
209
+ describe('queueTrackingEvent', () => {
210
+ it('Should store the given data in window object', () => {
211
+ queueTrackingEvent('Some event', null, { key: 'value' })
212
+
213
+ expect(window.PlayPilotLinkInjections.queued_tracking_events).toEqual([{ event: 'Some event', title: null, payload: { key: 'value' } }])
214
+
215
+ queueTrackingEvent('Some second event', null, { key: 'value' })
216
+
217
+ expect(window.PlayPilotLinkInjections.queued_tracking_events).toEqual([
218
+ { event: 'Some event', title: null, payload: { key: 'value' } },
219
+ { event: 'Some second event', title: null, payload: { key: 'value' } },
220
+ ])
221
+ })
222
+ })
223
+
224
+ describe('fireQueuedTrackingEvents', () => {
225
+ it('Should not fire queued events if consent is not given', () => {
226
+ vi.mocked(hasConsentedTo).mockImplementation(() => false)
227
+
228
+ queueTrackingEvent('Some event', null, { key: 'value' })
229
+
230
+ fireQueuedTrackingEvents()
231
+
232
+ expect(global.fetch).not.toHaveBeenCalled()
233
+ })
234
+
235
+ it('Should fire all queued events if consent is given', () => {
236
+ vi.mocked(hasConsentedTo).mockImplementation(() => true)
237
+
238
+ queueTrackingEvent('Some event', null, { key: 'value' })
239
+ queueTrackingEvent('Some second event', null, { key: 'value' })
240
+
241
+ fireQueuedTrackingEvents()
242
+
243
+ expect(global.fetch).toHaveBeenCalledTimes(2)
244
+ })
245
+ })
207
246
  })
@@ -12,6 +12,7 @@ import { isCrawler } from '$lib/crawler'
12
12
  import { getFullUrlPath } from '$lib/url'
13
13
  import { fetchAds } from '$lib/api/ads'
14
14
  import { fetchConfig } from '$lib/api/config'
15
+ import { hasConsentedTo } from '$lib/consent'
15
16
 
16
17
  vi.mock('$lib/api/externalPages', () => ({
17
18
  fetchLinkInjections: vi.fn(() => {}),
@@ -42,6 +43,7 @@ vi.mock(import('$lib/injection'), async (importOriginal) => {
42
43
  vi.mock('$lib/tracking', () => ({
43
44
  setTrackingSids: vi.fn(),
44
45
  track: vi.fn(),
46
+ fireQueuedTrackingEvents: vi.fn(),
45
47
  }))
46
48
 
47
49
  vi.mock(import('$lib/api/auth'), async (importOriginal) => {
@@ -74,12 +76,18 @@ vi.mock('$lib/api/api', () => ({
74
76
  api: vi.fn(),
75
77
  }))
76
78
 
79
+ vi.mock('$lib/consent', () => ({
80
+ setConsent: vi.fn(),
81
+ hasConsentedTo: vi.fn(() => true),
82
+ }))
83
+
77
84
  describe('$routes/+page.svelte', () => {
78
85
  beforeEach(() => {
79
86
  document.body.innerHTML = ''
80
87
  vi.resetAllMocks()
81
88
  vi.mocked(injectLinksInDocument).mockReturnValue([])
82
89
  vi.mocked(getFullUrlPath).mockReturnValue('/test')
90
+ vi.mocked(hasConsentedTo).mockImplementation(() => true)
83
91
  })
84
92
 
85
93
  afterEach(() => {