@prosdevlab/experience-sdk-plugins 0.1.4 → 0.3.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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +150 -0
- package/README.md +141 -79
- package/dist/index.d.ts +813 -35
- package/dist/index.js +1910 -66
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/banner/banner.ts +63 -62
- package/src/exit-intent/exit-intent.test.ts +423 -0
- package/src/exit-intent/exit-intent.ts +371 -0
- package/src/exit-intent/index.ts +6 -0
- package/src/exit-intent/types.ts +59 -0
- package/src/index.ts +7 -0
- package/src/inline/index.ts +3 -0
- package/src/inline/inline.test.ts +620 -0
- package/src/inline/inline.ts +269 -0
- package/src/inline/insertion.ts +66 -0
- package/src/inline/types.ts +52 -0
- package/src/integration.test.ts +421 -0
- package/src/modal/form-rendering.ts +262 -0
- package/src/modal/form-styles.ts +212 -0
- package/src/modal/form-validation.test.ts +413 -0
- package/src/modal/form-validation.ts +126 -0
- package/src/modal/index.ts +3 -0
- package/src/modal/modal-styles.ts +204 -0
- package/src/modal/modal.browser.test.ts +164 -0
- package/src/modal/modal.test.ts +1294 -0
- package/src/modal/modal.ts +685 -0
- package/src/modal/types.ts +114 -0
- package/src/page-visits/index.ts +6 -0
- package/src/page-visits/page-visits.test.ts +562 -0
- package/src/page-visits/page-visits.ts +314 -0
- package/src/page-visits/types.ts +119 -0
- package/src/scroll-depth/index.ts +6 -0
- package/src/scroll-depth/scroll-depth.test.ts +580 -0
- package/src/scroll-depth/scroll-depth.ts +398 -0
- package/src/scroll-depth/types.ts +122 -0
- package/src/time-delay/index.ts +6 -0
- package/src/time-delay/time-delay.test.ts +477 -0
- package/src/time-delay/time-delay.ts +296 -0
- package/src/time-delay/types.ts +89 -0
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +5 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/** @module timeDelayPlugin */
|
|
2
|
+
|
|
3
|
+
import type { PluginFunction } from '@lytics/sdk-kit';
|
|
4
|
+
import type { TimeDelayEvent, TimeDelayPlugin, TimeDelayPluginConfig } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure function: Calculate elapsed time from start
|
|
8
|
+
*/
|
|
9
|
+
export function calculateElapsed(startTime: number, pausedDuration: number): number {
|
|
10
|
+
return Date.now() - startTime - pausedDuration;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pure function: Check if document is hidden (Page Visibility API)
|
|
15
|
+
*/
|
|
16
|
+
export function isDocumentHidden(): boolean {
|
|
17
|
+
if (typeof document === 'undefined') return false;
|
|
18
|
+
return document.hidden || false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure function: Create time delay event payload
|
|
23
|
+
*/
|
|
24
|
+
export function createTimeDelayEvent(
|
|
25
|
+
startTime: number,
|
|
26
|
+
pausedDuration: number,
|
|
27
|
+
wasPaused: boolean,
|
|
28
|
+
visibilityChanges: number
|
|
29
|
+
): TimeDelayEvent {
|
|
30
|
+
const timestamp = Date.now();
|
|
31
|
+
const elapsed = timestamp - startTime;
|
|
32
|
+
const activeElapsed = elapsed - pausedDuration;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
timestamp,
|
|
36
|
+
elapsed,
|
|
37
|
+
activeElapsed,
|
|
38
|
+
wasPaused,
|
|
39
|
+
visibilityChanges,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Time Delay Plugin
|
|
45
|
+
*
|
|
46
|
+
* Tracks time elapsed since SDK initialization and emits trigger:timeDelay events
|
|
47
|
+
* when the configured delay is reached.
|
|
48
|
+
*
|
|
49
|
+
* **Features:**
|
|
50
|
+
* - Millisecond precision timing
|
|
51
|
+
* - Pause/resume on tab visibility change (optional)
|
|
52
|
+
* - Tracks active vs total elapsed time
|
|
53
|
+
* - Full timer lifecycle management
|
|
54
|
+
*
|
|
55
|
+
* **Event-Driven Architecture:**
|
|
56
|
+
* This plugin emits `trigger:timeDelay` events when the delay threshold is reached.
|
|
57
|
+
* The core runtime listens for these events and automatically re-evaluates experiences.
|
|
58
|
+
*
|
|
59
|
+
* **Usage Pattern:**
|
|
60
|
+
* Use `targeting.custom` to check if time delay has triggered:
|
|
61
|
+
*
|
|
62
|
+
* @example Basic usage
|
|
63
|
+
* ```typescript
|
|
64
|
+
* import { init, register } from '@prosdevlab/experience-sdk';
|
|
65
|
+
*
|
|
66
|
+
* init({
|
|
67
|
+
* timeDelay: {
|
|
68
|
+
* delay: 5000, // 5 seconds
|
|
69
|
+
* pauseWhenHidden: true // Pause when tab hidden (default)
|
|
70
|
+
* }
|
|
71
|
+
* });
|
|
72
|
+
*
|
|
73
|
+
* // Show banner after 5 seconds of active viewing time
|
|
74
|
+
* register('timed-offer', {
|
|
75
|
+
* type: 'banner',
|
|
76
|
+
* content: {
|
|
77
|
+
* message: 'Limited time offer!',
|
|
78
|
+
* buttons: [{ text: 'Claim Now', variant: 'primary' }]
|
|
79
|
+
* },
|
|
80
|
+
* targeting: {
|
|
81
|
+
* custom: (context) => {
|
|
82
|
+
* const active = context.triggers?.timeDelay?.activeElapsed || 0;
|
|
83
|
+
* return active >= 5000;
|
|
84
|
+
* }
|
|
85
|
+
* }
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @example Combining with other triggers
|
|
90
|
+
* ```typescript
|
|
91
|
+
* // Show after 10s OR on exit intent (whichever comes first)
|
|
92
|
+
* register('engaged-offer', {
|
|
93
|
+
* type: 'banner',
|
|
94
|
+
* content: { message: 'Special offer for engaged users!' },
|
|
95
|
+
* targeting: {
|
|
96
|
+
* custom: (context) => {
|
|
97
|
+
* const timeElapsed = (context.triggers?.timeDelay?.activeElapsed || 0) >= 10000;
|
|
98
|
+
* const exitIntent = context.triggers?.exitIntent?.triggered;
|
|
99
|
+
* return timeElapsed || exitIntent;
|
|
100
|
+
* }
|
|
101
|
+
* }
|
|
102
|
+
* });
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* @param plugin Plugin interface from sdk-kit
|
|
106
|
+
* @param instance SDK instance
|
|
107
|
+
* @param config SDK configuration
|
|
108
|
+
*/
|
|
109
|
+
export const timeDelayPlugin: PluginFunction = (plugin, instance, config) => {
|
|
110
|
+
plugin.ns('experiences.timeDelay');
|
|
111
|
+
|
|
112
|
+
// Set defaults
|
|
113
|
+
plugin.defaults({
|
|
114
|
+
timeDelay: {
|
|
115
|
+
delay: 0,
|
|
116
|
+
pauseWhenHidden: true,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Get config
|
|
121
|
+
const timeDelayConfig = config.get('timeDelay') as TimeDelayPluginConfig['timeDelay'];
|
|
122
|
+
if (!timeDelayConfig) return;
|
|
123
|
+
|
|
124
|
+
const delay = timeDelayConfig.delay ?? 0;
|
|
125
|
+
const pauseWhenHidden = timeDelayConfig.pauseWhenHidden ?? true;
|
|
126
|
+
|
|
127
|
+
// Skip if delay is 0 (disabled)
|
|
128
|
+
if (delay <= 0) return;
|
|
129
|
+
|
|
130
|
+
// State
|
|
131
|
+
const startTime = Date.now();
|
|
132
|
+
let triggered = false;
|
|
133
|
+
let paused = false;
|
|
134
|
+
let pausedDuration = 0;
|
|
135
|
+
let lastPauseTime = 0;
|
|
136
|
+
let visibilityChanges = 0;
|
|
137
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
138
|
+
let visibilityListener: (() => void) | null = null;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Trigger the time delay event
|
|
142
|
+
*/
|
|
143
|
+
function trigger(): void {
|
|
144
|
+
if (triggered) return;
|
|
145
|
+
|
|
146
|
+
triggered = true;
|
|
147
|
+
|
|
148
|
+
// Create event payload using pure function
|
|
149
|
+
const eventPayload = createTimeDelayEvent(
|
|
150
|
+
startTime,
|
|
151
|
+
pausedDuration,
|
|
152
|
+
visibilityChanges > 0,
|
|
153
|
+
visibilityChanges
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Emit trigger event
|
|
157
|
+
instance.emit('trigger:timeDelay', eventPayload);
|
|
158
|
+
|
|
159
|
+
// Cleanup
|
|
160
|
+
cleanup();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Schedule timer with remaining delay
|
|
165
|
+
*/
|
|
166
|
+
function scheduleTimer(remainingDelay: number): void {
|
|
167
|
+
if (timer) {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
trigger();
|
|
173
|
+
}, remainingDelay);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Handle visibility change
|
|
178
|
+
*/
|
|
179
|
+
function handleVisibilityChange(): void {
|
|
180
|
+
const hidden = isDocumentHidden();
|
|
181
|
+
|
|
182
|
+
if (hidden && !paused) {
|
|
183
|
+
// Tab just became hidden - pause timer
|
|
184
|
+
paused = true;
|
|
185
|
+
lastPauseTime = Date.now();
|
|
186
|
+
visibilityChanges++;
|
|
187
|
+
|
|
188
|
+
// Clear existing timer
|
|
189
|
+
if (timer) {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
timer = null;
|
|
192
|
+
}
|
|
193
|
+
} else if (!hidden && paused) {
|
|
194
|
+
// Tab just became visible - resume timer
|
|
195
|
+
paused = false;
|
|
196
|
+
const pauseDuration = Date.now() - lastPauseTime;
|
|
197
|
+
pausedDuration += pauseDuration;
|
|
198
|
+
visibilityChanges++;
|
|
199
|
+
|
|
200
|
+
// Calculate remaining delay
|
|
201
|
+
const elapsed = calculateElapsed(startTime, pausedDuration);
|
|
202
|
+
const remaining = delay - elapsed;
|
|
203
|
+
|
|
204
|
+
if (remaining > 0) {
|
|
205
|
+
scheduleTimer(remaining);
|
|
206
|
+
} else {
|
|
207
|
+
// Delay already elapsed during pause
|
|
208
|
+
trigger();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Cleanup listeners and timers
|
|
215
|
+
*/
|
|
216
|
+
function cleanup(): void {
|
|
217
|
+
if (timer) {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
timer = null;
|
|
220
|
+
}
|
|
221
|
+
if (visibilityListener && typeof document !== 'undefined') {
|
|
222
|
+
document.removeEventListener('visibilitychange', visibilityListener);
|
|
223
|
+
visibilityListener = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Initialize timer and visibility listener
|
|
229
|
+
*/
|
|
230
|
+
function initialize(): void {
|
|
231
|
+
// Check if already hidden on init
|
|
232
|
+
if (pauseWhenHidden && isDocumentHidden()) {
|
|
233
|
+
paused = true;
|
|
234
|
+
lastPauseTime = Date.now();
|
|
235
|
+
visibilityChanges++;
|
|
236
|
+
} else {
|
|
237
|
+
// Start timer
|
|
238
|
+
scheduleTimer(delay);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Setup visibility listener if pause is enabled
|
|
242
|
+
if (pauseWhenHidden && typeof document !== 'undefined') {
|
|
243
|
+
visibilityListener = handleVisibilityChange;
|
|
244
|
+
document.addEventListener('visibilitychange', visibilityListener);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Expose API
|
|
249
|
+
plugin.expose({
|
|
250
|
+
timeDelay: {
|
|
251
|
+
getElapsed: () => {
|
|
252
|
+
return Date.now() - startTime;
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
getActiveElapsed: () => {
|
|
256
|
+
let currentPausedDuration = pausedDuration;
|
|
257
|
+
if (paused) {
|
|
258
|
+
// Add current pause duration
|
|
259
|
+
currentPausedDuration += Date.now() - lastPauseTime;
|
|
260
|
+
}
|
|
261
|
+
return calculateElapsed(startTime, currentPausedDuration);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
getRemaining: () => {
|
|
265
|
+
if (triggered) return 0;
|
|
266
|
+
|
|
267
|
+
const elapsed = calculateElapsed(startTime, pausedDuration);
|
|
268
|
+
const remaining = delay - elapsed;
|
|
269
|
+
return Math.max(0, remaining);
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
isPaused: () => paused,
|
|
273
|
+
|
|
274
|
+
isTriggered: () => triggered,
|
|
275
|
+
|
|
276
|
+
reset: () => {
|
|
277
|
+
triggered = false;
|
|
278
|
+
paused = false;
|
|
279
|
+
pausedDuration = 0;
|
|
280
|
+
lastPauseTime = 0;
|
|
281
|
+
visibilityChanges = 0;
|
|
282
|
+
|
|
283
|
+
cleanup();
|
|
284
|
+
initialize();
|
|
285
|
+
},
|
|
286
|
+
} satisfies TimeDelayPlugin,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Initialize on plugin load
|
|
290
|
+
initialize();
|
|
291
|
+
|
|
292
|
+
// Cleanup on instance destroy
|
|
293
|
+
instance.on('sdk:destroy', () => {
|
|
294
|
+
cleanup();
|
|
295
|
+
});
|
|
296
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** @module timeDelayPlugin */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time Delay Plugin Configuration
|
|
5
|
+
*
|
|
6
|
+
* Tracks time elapsed since SDK initialization and emits trigger:timeDelay events.
|
|
7
|
+
*/
|
|
8
|
+
export interface TimeDelayPluginConfig {
|
|
9
|
+
timeDelay?: {
|
|
10
|
+
/**
|
|
11
|
+
* Delay before emitting trigger event (milliseconds).
|
|
12
|
+
* Set to 0 to disable (immediate trigger on init).
|
|
13
|
+
* @default 0
|
|
14
|
+
* @example 5000 // 5 seconds
|
|
15
|
+
*/
|
|
16
|
+
delay?: number;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pause timer when tab is hidden (Page Visibility API).
|
|
20
|
+
* When true, only counts "active viewing time".
|
|
21
|
+
* When false, timer runs even when tab is hidden.
|
|
22
|
+
* @default true
|
|
23
|
+
*/
|
|
24
|
+
pauseWhenHidden?: boolean;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Time Delay Event Payload
|
|
30
|
+
*
|
|
31
|
+
* Emitted via 'trigger:timeDelay' when the configured delay is reached.
|
|
32
|
+
*/
|
|
33
|
+
export interface TimeDelayEvent {
|
|
34
|
+
/** Timestamp when the trigger event was emitted */
|
|
35
|
+
timestamp: number;
|
|
36
|
+
|
|
37
|
+
/** Total elapsed time since init (milliseconds, includes paused time) */
|
|
38
|
+
elapsed: number;
|
|
39
|
+
|
|
40
|
+
/** Active elapsed time (milliseconds, excludes time when tab was hidden) */
|
|
41
|
+
activeElapsed: number;
|
|
42
|
+
|
|
43
|
+
/** Whether the timer was paused at any point */
|
|
44
|
+
wasPaused: boolean;
|
|
45
|
+
|
|
46
|
+
/** Number of times visibility changed (hidden/visible) */
|
|
47
|
+
visibilityChanges: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Time Delay Plugin API
|
|
52
|
+
*/
|
|
53
|
+
export interface TimeDelayPlugin {
|
|
54
|
+
/**
|
|
55
|
+
* Get total elapsed time since init (includes paused time)
|
|
56
|
+
* @returns Time in milliseconds
|
|
57
|
+
*/
|
|
58
|
+
getElapsed(): number;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get active elapsed time (excludes paused time)
|
|
62
|
+
* @returns Time in milliseconds
|
|
63
|
+
*/
|
|
64
|
+
getActiveElapsed(): number;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get remaining time until trigger
|
|
68
|
+
* @returns Time in milliseconds, or 0 if already triggered
|
|
69
|
+
*/
|
|
70
|
+
getRemaining(): number;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if timer is currently paused (tab hidden)
|
|
74
|
+
* @returns True if paused
|
|
75
|
+
*/
|
|
76
|
+
isPaused(): boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if trigger has fired
|
|
80
|
+
* @returns True if triggered
|
|
81
|
+
*/
|
|
82
|
+
isTriggered(): boolean;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reset timer to initial state
|
|
86
|
+
* Clears trigger flag and restarts timing
|
|
87
|
+
*/
|
|
88
|
+
reset(): void;
|
|
89
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -3,10 +3,25 @@
|
|
|
3
3
|
* These types are re-exported by core for user convenience
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { InlineContent as _InlineContent } from './inline/types';
|
|
7
|
+
// Import modal and inline content types from their plugins
|
|
8
|
+
import type { ModalContent as _ModalContent } from './modal/types';
|
|
9
|
+
export type ModalContent = _ModalContent;
|
|
10
|
+
export type InlineContent = _InlineContent;
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
|
-
* Experience
|
|
13
|
+
* Experience button configuration (used across all experience types)
|
|
8
14
|
*/
|
|
9
|
-
export
|
|
15
|
+
export interface ExperienceButton {
|
|
16
|
+
text: string;
|
|
17
|
+
action?: string;
|
|
18
|
+
url?: string;
|
|
19
|
+
variant?: 'primary' | 'secondary' | 'link';
|
|
20
|
+
dismiss?: boolean;
|
|
21
|
+
metadata?: Record<string, any>;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: Record<string, string>;
|
|
24
|
+
}
|
|
10
25
|
|
|
11
26
|
/**
|
|
12
27
|
* Banner content configuration
|
|
@@ -14,31 +29,13 @@ export type ExperienceContent = BannerContent | ModalContent | TooltipContent;
|
|
|
14
29
|
export interface BannerContent {
|
|
15
30
|
title?: string;
|
|
16
31
|
message: string;
|
|
17
|
-
buttons?:
|
|
18
|
-
text: string;
|
|
19
|
-
action?: string;
|
|
20
|
-
url?: string;
|
|
21
|
-
variant?: 'primary' | 'secondary' | 'link';
|
|
22
|
-
metadata?: Record<string, any>;
|
|
23
|
-
className?: string;
|
|
24
|
-
style?: Record<string, string>;
|
|
25
|
-
}>;
|
|
32
|
+
buttons?: ExperienceButton[];
|
|
26
33
|
dismissable?: boolean;
|
|
27
34
|
position?: 'top' | 'bottom';
|
|
28
35
|
className?: string;
|
|
29
36
|
style?: Record<string, string>;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
/**
|
|
33
|
-
* Modal content configuration
|
|
34
|
-
*/
|
|
35
|
-
export interface ModalContent {
|
|
36
|
-
title: string;
|
|
37
|
-
message: string;
|
|
38
|
-
confirmText?: string;
|
|
39
|
-
cancelText?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
39
|
/**
|
|
43
40
|
* Tooltip content configuration
|
|
44
41
|
*/
|
|
@@ -48,22 +45,9 @@ export interface TooltipContent {
|
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
/**
|
|
51
|
-
*
|
|
52
|
-
*/
|
|
53
|
-
export interface ModalContent {
|
|
54
|
-
title: string;
|
|
55
|
-
message: string;
|
|
56
|
-
confirmText?: string;
|
|
57
|
-
cancelText?: string;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Tooltip content configuration
|
|
48
|
+
* Experience content - varies by type
|
|
62
49
|
*/
|
|
63
|
-
export
|
|
64
|
-
message: string;
|
|
65
|
-
position?: 'top' | 'bottom' | 'left' | 'right';
|
|
66
|
-
}
|
|
50
|
+
export type ExperienceContent = BannerContent | ModalContent | InlineContent | TooltipContent;
|
|
67
51
|
|
|
68
52
|
/**
|
|
69
53
|
* Experience definition
|
package/src/utils/sanitize.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Allowed HTML tags for sanitization
|
|
12
12
|
* Only safe formatting tags are permitted
|
|
13
13
|
*/
|
|
14
|
-
const ALLOWED_TAGS = ['strong', 'em', 'a', 'br', 'span', 'b', 'i', 'p'] as const;
|
|
14
|
+
const ALLOWED_TAGS = ['strong', 'em', 'a', 'br', 'span', 'b', 'i', 'p', 'div', 'ul', 'li'] as const;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Allowed attributes per tag
|
|
@@ -20,6 +20,9 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
|
|
20
20
|
a: ['href', 'class', 'style', 'title'],
|
|
21
21
|
span: ['class', 'style'],
|
|
22
22
|
p: ['class', 'style'],
|
|
23
|
+
div: ['class', 'style'],
|
|
24
|
+
ul: ['class', 'style'],
|
|
25
|
+
li: ['class', 'style'],
|
|
23
26
|
// Other tags have no attributes allowed
|
|
24
27
|
};
|
|
25
28
|
|
|
@@ -92,7 +95,7 @@ export function sanitizeHTML(html: string): string {
|
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
const attrString = attrs.length > 0 ?
|
|
98
|
+
const attrString = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
|
|
96
99
|
|
|
97
100
|
// Process child nodes
|
|
98
101
|
let innerHTML = '';
|