@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/.github/workflows/create-asana-attachment.yml +18 -0
- package/dist/link-injections.js +10 -10
- package/package.json +1 -1
- package/src/lib/injection.ts +6 -0
- package/src/lib/meta.ts +14 -2
- package/src/lib/tracking.ts +26 -1
- package/src/lib/types/script.d.ts +3 -0
- package/src/main.ts +5 -0
- package/src/routes/+page.svelte +10 -5
- package/src/tests/lib/injections.test.js +11 -0
- package/src/tests/lib/meta.test.js +65 -1
- package/src/tests/lib/tracking.test.js +41 -2
- package/src/tests/routes/+page.test.js +8 -0
package/package.json
CHANGED
package/src/lib/injection.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/tracking.ts
CHANGED
|
@@ -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
|
-
|
|
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()
|
package/src/routes/+page.svelte
CHANGED
|
@@ -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
|
|
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={
|
|
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(() => {
|