@drip-apex/sdk 0.1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActivationMode,
|
|
3
|
+
Assignment,
|
|
4
|
+
Experiment,
|
|
5
|
+
InitConfig,
|
|
6
|
+
PerfFeatureFlag,
|
|
7
|
+
PlatformHint,
|
|
8
|
+
RouterMode,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
type UrlDiffNeeds = {
|
|
12
|
+
path: boolean;
|
|
13
|
+
search: boolean;
|
|
14
|
+
hash: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PerfFlagState = Record<PerfFeatureFlag, boolean>;
|
|
18
|
+
|
|
19
|
+
const PERF_FLAG_DEFAULTS: PerfFlagState = {
|
|
20
|
+
perf_platform_router_v1: true,
|
|
21
|
+
perf_shopify_pixel_buffer_v1: true,
|
|
22
|
+
perf_spa_incremental_reapply_v1: true,
|
|
23
|
+
perf_empty_project_v1: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function hasOwnEntries(value: unknown): boolean {
|
|
27
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasProjectCode(config: InitConfig): boolean {
|
|
31
|
+
const projectCode = config.projectCode;
|
|
32
|
+
return Boolean(
|
|
33
|
+
projectCode?.enabled
|
|
34
|
+
|| projectCode?.css?.trim()
|
|
35
|
+
|| projectCode?.helperJs?.trim()
|
|
36
|
+
|| projectCode?.js?.trim(),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Browser-side validation for a server-compiled empty-project profile. The
|
|
42
|
+
* browser may reject the profile, but never upgrades a full profile itself.
|
|
43
|
+
*/
|
|
44
|
+
export function canUseEmptyProjectProfile(
|
|
45
|
+
config: InitConfig,
|
|
46
|
+
experiments: Experiment[],
|
|
47
|
+
platform?: PlatformHint,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (config.runtimeProfile !== "empty_project") return false;
|
|
50
|
+
if (!readPerfFlags(config).perf_empty_project_v1) return false;
|
|
51
|
+
if (experiments.length > 0) return false;
|
|
52
|
+
if ((config.signals?.length ?? 0) > 0) return false;
|
|
53
|
+
if (hasOwnEntries(config.commerce)) return false;
|
|
54
|
+
if ((config.dataLayerTriggers?.length ?? 0) > 0) return false;
|
|
55
|
+
if ((config.externalTracking?.domains?.length ?? 0) > 0) return false;
|
|
56
|
+
if (hasOwnEntries(config.exposureDestinations)) return false;
|
|
57
|
+
if (hasProjectCode(config)) return false;
|
|
58
|
+
if (config.semanticTracking?.enabled) return false;
|
|
59
|
+
if (config.debug?.enabled) return false;
|
|
60
|
+
if (config.runtimeFeatures && Object.entries(config.runtimeFeatures).some(([key, enabled]) => (
|
|
61
|
+
key !== "sdkVariant" && enabled === true
|
|
62
|
+
))) return false;
|
|
63
|
+
if (config.runtimeFeatures?.sdkVariant && config.runtimeFeatures.sdkVariant !== "live") return false;
|
|
64
|
+
if (config.pageTrigger?.defaultMode && config.pageTrigger.defaultMode !== "direct") return false;
|
|
65
|
+
if (config.routerMode && config.routerMode !== "auto" && config.routerMode !== "none") return false;
|
|
66
|
+
if (config.spa && (
|
|
67
|
+
config.spa.reinitOnly
|
|
68
|
+
|| config.spa.domChangeTrigger
|
|
69
|
+
|| config.spa.activationMode === "manual"
|
|
70
|
+
|| (config.spa.routerMode && config.spa.routerMode !== "auto" && config.spa.routerMode !== "none")
|
|
71
|
+
)) return false;
|
|
72
|
+
if (config.profile?.platform === "spa" || config.platformHint === "spa" || platform === "spa") return false;
|
|
73
|
+
if (config.projectPrerequisites?.showVariationsRule?.trim()
|
|
74
|
+
&& config.projectPrerequisites.showVariationsRule.trim() !== "return true;") return false;
|
|
75
|
+
if (config.projectPrerequisites?.startTrackingRule?.trim()
|
|
76
|
+
&& config.projectPrerequisites.startTrackingRule.trim() !== "return true;") return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasNavigationApi(): boolean {
|
|
81
|
+
const w = globalThis as Record<string, unknown>;
|
|
82
|
+
const nav = w.navigation as { addEventListener?: unknown } | undefined;
|
|
83
|
+
return Boolean(nav && typeof nav.addEventListener === "function");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasShopifyRuntime(): boolean {
|
|
87
|
+
const w = globalThis as Record<string, unknown>;
|
|
88
|
+
const shopify = w.Shopify as { analytics?: { publish?: unknown } } | undefined;
|
|
89
|
+
return Boolean(shopify?.analytics && typeof shopify.analytics.publish === "function");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasShopwareRuntime(): boolean {
|
|
93
|
+
const w = globalThis as Record<string, unknown>;
|
|
94
|
+
if (w.PluginManager) return true;
|
|
95
|
+
if (w.Shopware) return true;
|
|
96
|
+
if (typeof document === "undefined") return false;
|
|
97
|
+
return Boolean(
|
|
98
|
+
document.querySelector("[data-shopware], [data-off-canvas-main-navigation], .cms-page, .is--ctl-index"),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasSpaMarkers(): boolean {
|
|
103
|
+
if (typeof document === "undefined") return false;
|
|
104
|
+
return Boolean(
|
|
105
|
+
document.getElementById("__next")
|
|
106
|
+
|| document.querySelector("[data-reactroot], [ng-version], [data-sveltekit], [data-v-app], [data-router-view]"),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function detectRuntimePlatform(): PlatformHint {
|
|
111
|
+
if (hasShopifyRuntime()) return "shopify";
|
|
112
|
+
if (hasShopwareRuntime()) return "shopware";
|
|
113
|
+
if (hasSpaMarkers() || hasNavigationApi()) return "spa";
|
|
114
|
+
return "auto";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolvePlatform(config: InitConfig): PlatformHint {
|
|
118
|
+
if (config.platformHint && config.platformHint !== "auto") {
|
|
119
|
+
return config.platformHint;
|
|
120
|
+
}
|
|
121
|
+
const profilePlatform = config.profile?.platform;
|
|
122
|
+
if (profilePlatform && profilePlatform !== "auto") {
|
|
123
|
+
return profilePlatform;
|
|
124
|
+
}
|
|
125
|
+
return detectRuntimePlatform();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveRouterMode(config: InitConfig, platform: PlatformHint): RouterMode {
|
|
129
|
+
if (config.spa?.reinitOnly) return "none";
|
|
130
|
+
const requested = config.spa?.routerMode ?? config.routerMode ?? "auto";
|
|
131
|
+
if (requested !== "auto") return requested;
|
|
132
|
+
if (platform === "shopify" && !config.spa?.domChangeTrigger) return "none";
|
|
133
|
+
return hasNavigationApi() ? "navigation" : "history";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resolveActivationMode(config: InitConfig): ActivationMode {
|
|
137
|
+
const requested = config.spa?.activationMode ?? config.activationMode ?? "auto";
|
|
138
|
+
if (requested === "manual") return "manual";
|
|
139
|
+
return "observer";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readPerfFlags(config: InitConfig): PerfFlagState {
|
|
143
|
+
const out = { ...PERF_FLAG_DEFAULTS };
|
|
144
|
+
const configured = config.perfFlags ?? {};
|
|
145
|
+
for (const key of Object.keys(out) as PerfFeatureFlag[]) {
|
|
146
|
+
if (typeof configured[key] === "boolean") out[key] = configured[key]!;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseMaybeUrl(url: string): URL | null {
|
|
152
|
+
try {
|
|
153
|
+
return new URL(url, "https://_");
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readForceSignature(params: URLSearchParams): string {
|
|
160
|
+
const canonical = params.get("drip_force") ?? "";
|
|
161
|
+
if (canonical) return canonical;
|
|
162
|
+
const experimentId =
|
|
163
|
+
params.get("drip_experiment_id") ??
|
|
164
|
+
params.get("drip_experiment") ??
|
|
165
|
+
params.get("experiment_id") ??
|
|
166
|
+
params.get("experimentId") ??
|
|
167
|
+
params.get("expId") ??
|
|
168
|
+
"";
|
|
169
|
+
const variationId =
|
|
170
|
+
params.get("drip_variation_id") ??
|
|
171
|
+
params.get("drip_variation") ??
|
|
172
|
+
params.get("variation_id") ??
|
|
173
|
+
params.get("variationId") ??
|
|
174
|
+
params.get("varId") ??
|
|
175
|
+
"";
|
|
176
|
+
return experimentId && variationId ? `${experimentId}:${variationId}` : "";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function markUrlDepsForPattern(
|
|
180
|
+
deps: UrlDiffNeeds,
|
|
181
|
+
pattern: string,
|
|
182
|
+
type: "regex" | "simple",
|
|
183
|
+
): void {
|
|
184
|
+
if (type === "regex") {
|
|
185
|
+
deps.path = true;
|
|
186
|
+
deps.search = true;
|
|
187
|
+
deps.hash = true;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
deps.path = true;
|
|
191
|
+
if (pattern.includes("?")) deps.search = true;
|
|
192
|
+
if (pattern.includes("#")) deps.hash = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function computeUrlDiffNeeds(
|
|
196
|
+
experiments: Experiment[],
|
|
197
|
+
signals: InitConfig["signals"] = [],
|
|
198
|
+
): UrlDiffNeeds {
|
|
199
|
+
const deps: UrlDiffNeeds = { path: false, search: false, hash: false };
|
|
200
|
+
for (const exp of experiments) {
|
|
201
|
+
const rules = exp.targeting?.url;
|
|
202
|
+
if (!rules || rules.length === 0) continue;
|
|
203
|
+
for (const rule of rules) {
|
|
204
|
+
markUrlDepsForPattern(deps, rule.pattern, rule.type);
|
|
205
|
+
if (deps.path && deps.search && deps.hash) return deps;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const definition of signals ?? []) {
|
|
210
|
+
const mode = definition.capture?.mode;
|
|
211
|
+
if (mode === "url_parameter") {
|
|
212
|
+
deps.search = true;
|
|
213
|
+
}
|
|
214
|
+
if (mode === "dom_selector" || mode === "global") {
|
|
215
|
+
deps.path = true;
|
|
216
|
+
deps.search = true;
|
|
217
|
+
deps.hash = true;
|
|
218
|
+
}
|
|
219
|
+
if (deps.path && deps.search && deps.hash) return deps;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return deps;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function shouldReevaluateForUrlChange(
|
|
226
|
+
previousUrl: string,
|
|
227
|
+
nextUrl: string,
|
|
228
|
+
deps: UrlDiffNeeds,
|
|
229
|
+
): boolean {
|
|
230
|
+
if (previousUrl === nextUrl) return false;
|
|
231
|
+
const prev = parseMaybeUrl(previousUrl);
|
|
232
|
+
const next = parseMaybeUrl(nextUrl);
|
|
233
|
+
if (!prev || !next) return true;
|
|
234
|
+
if (deps.path && prev.pathname !== next.pathname) return true;
|
|
235
|
+
if (deps.search && prev.search !== next.search) return true;
|
|
236
|
+
if (deps.hash && prev.hash !== next.hash) return true;
|
|
237
|
+
if (!deps.path && !deps.search && !deps.hash) {
|
|
238
|
+
const prevForce = readForceSignature(prev.searchParams);
|
|
239
|
+
const nextForce = readForceSignature(next.searchParams);
|
|
240
|
+
return prevForce !== nextForce;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function assignmentSignature(assignments: Assignment[]): string {
|
|
246
|
+
if (!assignments.length) return "none";
|
|
247
|
+
return assignments
|
|
248
|
+
.map((item) => item.experimentId + ":" + item.variationId)
|
|
249
|
+
.sort()
|
|
250
|
+
.join("|");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function getBlankScreenMs(): number {
|
|
254
|
+
try {
|
|
255
|
+
if (typeof performance === "undefined") return 0;
|
|
256
|
+
const timing = performance.timing;
|
|
257
|
+
if (timing && timing.navigationStart > 0 && timing.domContentLoadedEventStart > 0) {
|
|
258
|
+
return Math.max(0, timing.domContentLoadedEventStart - timing.navigationStart);
|
|
259
|
+
}
|
|
260
|
+
const nav = performance.getEntriesByType?.("navigation")?.[0] as { domContentLoadedEventStart?: number } | undefined;
|
|
261
|
+
if (nav && typeof nav.domContentLoadedEventStart === "number") {
|
|
262
|
+
return Math.max(0, Math.round(nav.domContentLoadedEventStart));
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// ignore perf API failures
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe wrapper around RegExp construction and testing.
|
|
3
|
+
* Prevents crashes from invalid patterns and guards against
|
|
4
|
+
* extremely long patterns that could cause ReDoS.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MAX_PATTERN_LENGTH = 1024;
|
|
8
|
+
const MAX_CACHE_ENTRIES = 256;
|
|
9
|
+
const regexCache = new Map<string, RegExp | null>();
|
|
10
|
+
|
|
11
|
+
function cacheRegex(pattern: string, compiled: RegExp | null): void {
|
|
12
|
+
if (!regexCache.has(pattern) && regexCache.size >= MAX_CACHE_ENTRIES) {
|
|
13
|
+
const oldest = regexCache.keys().next().value;
|
|
14
|
+
if (oldest) regexCache.delete(oldest);
|
|
15
|
+
}
|
|
16
|
+
regexCache.set(pattern, compiled);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function safeRegexTest(pattern: string, input: string): boolean {
|
|
20
|
+
if (!pattern || pattern.length > MAX_PATTERN_LENGTH) return false;
|
|
21
|
+
|
|
22
|
+
if (regexCache.has(pattern)) {
|
|
23
|
+
const cached = regexCache.get(pattern);
|
|
24
|
+
return cached ? cached.test(input) : false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const compiled = new RegExp(pattern);
|
|
29
|
+
cacheRegex(pattern, compiled);
|
|
30
|
+
return compiled.test(input);
|
|
31
|
+
} catch {
|
|
32
|
+
cacheRegex(pattern, null);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HTML sanitizer using the browser's DOMParser.
|
|
3
|
+
* Strips <script> tags, on* event handlers, and javascript: URLs.
|
|
4
|
+
*/
|
|
5
|
+
export function sanitizeHtml(html: string): string {
|
|
6
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
7
|
+
|
|
8
|
+
// Remove all <script> elements
|
|
9
|
+
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
|
10
|
+
script.remove();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Walk every element to strip dangerous attributes
|
|
14
|
+
for (const el of Array.from(doc.body.querySelectorAll("*"))) {
|
|
15
|
+
for (const attr of Array.from(el.attributes)) {
|
|
16
|
+
// Remove on* event handler attributes (onclick, onerror, etc.)
|
|
17
|
+
if (attr.name.toLowerCase().startsWith("on")) {
|
|
18
|
+
el.removeAttribute(attr.name);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Remove javascript: URLs in href, src, action, formaction, xlink:href
|
|
23
|
+
const urlAttrs = ["href", "src", "action", "formaction", "xlink:href"];
|
|
24
|
+
if (urlAttrs.includes(attr.name.toLowerCase())) {
|
|
25
|
+
const value = attr.value.replace(/\s+/g, "").toLowerCase();
|
|
26
|
+
if (value.startsWith("javascript:")) {
|
|
27
|
+
el.removeAttribute(attr.name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return doc.body.innerHTML;
|
|
34
|
+
}
|
package/src/semantic.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { SemanticTrackingRule, SemanticTrackingSettings } from "./types";
|
|
2
|
+
|
|
3
|
+
type TrackFn = (type: string, data?: Record<string, unknown>) => void;
|
|
4
|
+
|
|
5
|
+
type Controller = {
|
|
6
|
+
stop: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type NormalizedRule = Required<
|
|
10
|
+
Pick<
|
|
11
|
+
SemanticTrackingRule,
|
|
12
|
+
"id" | "eventType" | "action" | "selector" | "labelSource" | "indexStrategy" | "oncePerSession"
|
|
13
|
+
>
|
|
14
|
+
> &
|
|
15
|
+
Omit<
|
|
16
|
+
SemanticTrackingRule,
|
|
17
|
+
"id" | "eventType" | "action" | "selector" | "labelSource" | "indexStrategy" | "oncePerSession"
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
function clamp(value: number, min: number, max: number): number {
|
|
21
|
+
return Math.max(min, Math.min(max, value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeSampleRate(value: unknown): number {
|
|
25
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
26
|
+
? clamp(value, 0, 1)
|
|
27
|
+
: 0.25;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sampledIn(sampleRate: number): boolean {
|
|
31
|
+
if (sampleRate >= 1) return true;
|
|
32
|
+
if (sampleRate <= 0) return false;
|
|
33
|
+
const key = "drip_semantic_sample";
|
|
34
|
+
try {
|
|
35
|
+
const existing = sessionStorage.getItem(key);
|
|
36
|
+
if (existing === "1") return true;
|
|
37
|
+
if (existing === "0") return false;
|
|
38
|
+
const selected = Math.random() < sampleRate;
|
|
39
|
+
sessionStorage.setItem(key, selected ? "1" : "0");
|
|
40
|
+
return selected;
|
|
41
|
+
} catch {
|
|
42
|
+
return Math.random() < sampleRate;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cleanUrl(): string {
|
|
47
|
+
return location.origin + location.pathname;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeRules(settings: SemanticTrackingSettings): NormalizedRule[] {
|
|
51
|
+
const rules = Array.isArray(settings.rules) ? settings.rules : [];
|
|
52
|
+
return rules
|
|
53
|
+
.map((rule): NormalizedRule | null => {
|
|
54
|
+
const id = rule.id?.trim();
|
|
55
|
+
const eventType = rule.eventType?.trim();
|
|
56
|
+
const selector = rule.selector?.trim();
|
|
57
|
+
if (!id || !eventType || !selector) return null;
|
|
58
|
+
return {
|
|
59
|
+
...rule,
|
|
60
|
+
id,
|
|
61
|
+
eventType,
|
|
62
|
+
selector,
|
|
63
|
+
action: rule.action === "visibility" ? "visibility" : "click",
|
|
64
|
+
labelSource: rule.labelSource ?? "text",
|
|
65
|
+
indexStrategy: rule.indexStrategy ?? "dom_order",
|
|
66
|
+
oncePerSession: rule.oncePerSession !== false,
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.filter((rule): rule is NormalizedRule => Boolean(rule));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pathMatches(rule: NormalizedRule): boolean {
|
|
73
|
+
const pattern = rule.pathPattern?.trim();
|
|
74
|
+
if (!pattern) return true;
|
|
75
|
+
const path = `${location.pathname}${location.search}`;
|
|
76
|
+
if (pattern.startsWith("regex:")) {
|
|
77
|
+
try {
|
|
78
|
+
return new RegExp(pattern.slice(6)).test(path);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return path.includes(pattern);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function elementText(element: Element): string {
|
|
87
|
+
const tag = element.tagName.toLowerCase();
|
|
88
|
+
if (tag === "input" || tag === "textarea" || tag === "select") {
|
|
89
|
+
return (
|
|
90
|
+
element.getAttribute("aria-label") ||
|
|
91
|
+
element.getAttribute("title") ||
|
|
92
|
+
element.getAttribute("value") ||
|
|
93
|
+
""
|
|
94
|
+
).replace(/\s+/g, " ").trim().slice(0, 120);
|
|
95
|
+
}
|
|
96
|
+
return (
|
|
97
|
+
element.textContent ||
|
|
98
|
+
element.getAttribute("aria-label") ||
|
|
99
|
+
element.getAttribute("title") ||
|
|
100
|
+
element.getAttribute("alt") ||
|
|
101
|
+
""
|
|
102
|
+
).replace(/\s+/g, " ").trim().slice(0, 120);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function labelFor(rule: NormalizedRule, element: Element): string {
|
|
106
|
+
if (rule.labelSource === "constant") return rule.label || rule.elementRole || rule.id;
|
|
107
|
+
if (rule.labelSource === "attribute" && rule.attribute) {
|
|
108
|
+
return (element.getAttribute(rule.attribute) || rule.label || elementText(element)).slice(0, 180);
|
|
109
|
+
}
|
|
110
|
+
return (rule.label || elementText(element) || rule.elementRole || rule.id).slice(0, 180);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function productContext(): Record<string, unknown> {
|
|
114
|
+
const out: Record<string, unknown> = {};
|
|
115
|
+
const canonical = document.querySelector<HTMLLinkElement>("link[rel='canonical']")?.href;
|
|
116
|
+
const productId = document.querySelector<HTMLElement>("[data-product-id]")?.dataset.productId;
|
|
117
|
+
const handle = canonical ? canonical.split("/products/")[1]?.split(/[?#/]/)[0] : undefined;
|
|
118
|
+
if (productId) out.product_id = productId;
|
|
119
|
+
if (handle) out.product_handle = handle;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function elementIndex(rule: NormalizedRule, element: Element, elements: Element[]): number | null {
|
|
124
|
+
if (rule.indexStrategy === "none") return null;
|
|
125
|
+
const index = elements.indexOf(element);
|
|
126
|
+
return index >= 0 ? index + 1 : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function basePayload(rule: NormalizedRule, element: Element, elements: Element[]) {
|
|
130
|
+
const rect = element.getBoundingClientRect();
|
|
131
|
+
const index = elementIndex(rule, element, elements);
|
|
132
|
+
return {
|
|
133
|
+
semantic_rule_id: rule.id,
|
|
134
|
+
semantic_event: rule.eventType,
|
|
135
|
+
semantic_role: rule.elementRole ?? "",
|
|
136
|
+
semantic_label: labelFor(rule, element),
|
|
137
|
+
page_type: rule.pageType ?? "",
|
|
138
|
+
element_index: index,
|
|
139
|
+
element_tag: element.tagName.toLowerCase(),
|
|
140
|
+
element_text: elementText(element),
|
|
141
|
+
element_width: Math.round(rect.width),
|
|
142
|
+
element_height: Math.round(rect.height),
|
|
143
|
+
path: location.pathname,
|
|
144
|
+
page_url: cleanUrl(),
|
|
145
|
+
...productContext(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function dedupeKey(rule: NormalizedRule, element: Element, elements: Element[]): string {
|
|
150
|
+
const index = elementIndex(rule, element, elements) ?? 0;
|
|
151
|
+
return `${rule.id}:${cleanUrl()}:${index}:${labelFor(rule, element)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function startSemanticCapture(
|
|
155
|
+
settings: SemanticTrackingSettings | undefined,
|
|
156
|
+
track: TrackFn,
|
|
157
|
+
): Controller | null {
|
|
158
|
+
if (!settings?.enabled) return null;
|
|
159
|
+
if (!sampledIn(normalizeSampleRate(settings.sampleRate))) return null;
|
|
160
|
+
|
|
161
|
+
const rules = normalizeRules(settings).filter(pathMatches);
|
|
162
|
+
if (!rules.length) return null;
|
|
163
|
+
|
|
164
|
+
const maxEvents = Math.max(1, Math.min(500, Math.round(settings.maxEventsPerSession ?? 120)));
|
|
165
|
+
const minVisibleMs = Math.max(100, Math.min(10_000, Math.round(settings.minVisibleMs ?? 700)));
|
|
166
|
+
const threshold = Math.max(0.05, Math.min(1, settings.visibilityThreshold ?? 0.5));
|
|
167
|
+
const seen = new Set<string>();
|
|
168
|
+
const timers = new Map<Element, number>();
|
|
169
|
+
let emitted = 0;
|
|
170
|
+
|
|
171
|
+
function emit(rule: NormalizedRule, element: Element, elements: Element[], extra: Record<string, unknown> = {}) {
|
|
172
|
+
if (emitted >= maxEvents) return;
|
|
173
|
+
const key = dedupeKey(rule, element, elements);
|
|
174
|
+
if (rule.oncePerSession && seen.has(key)) return;
|
|
175
|
+
seen.add(key);
|
|
176
|
+
emitted += 1;
|
|
177
|
+
track(rule.eventType, { ...basePayload(rule, element, elements), ...extra });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const visibilityRules = rules.filter((rule) => rule.action === "visibility");
|
|
181
|
+
const clickRules = rules.filter((rule) => rule.action === "click");
|
|
182
|
+
const observers: IntersectionObserver[] = [];
|
|
183
|
+
|
|
184
|
+
for (const rule of visibilityRules) {
|
|
185
|
+
let elements: Element[] = [];
|
|
186
|
+
try {
|
|
187
|
+
elements = Array.from(document.querySelectorAll(rule.selector));
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!elements.length) continue;
|
|
192
|
+
const observer = new IntersectionObserver(
|
|
193
|
+
(entries) => {
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (entry.intersectionRatio < threshold) continue;
|
|
196
|
+
if (timers.has(entry.target)) continue;
|
|
197
|
+
const timer = window.setTimeout(() => {
|
|
198
|
+
timers.delete(entry.target);
|
|
199
|
+
const rect = entry.target.getBoundingClientRect();
|
|
200
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
201
|
+
emit(rule, entry.target, elements, {
|
|
202
|
+
visible_ms: minVisibleMs,
|
|
203
|
+
visibility_pct: Math.round(entry.intersectionRatio * 100),
|
|
204
|
+
});
|
|
205
|
+
}, minVisibleMs);
|
|
206
|
+
timers.set(entry.target, timer);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{ threshold: [threshold] },
|
|
210
|
+
);
|
|
211
|
+
elements.forEach((element) => observer.observe(element));
|
|
212
|
+
observers.push(observer);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const onClick = (event: MouseEvent) => {
|
|
216
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
217
|
+
if (!target) return;
|
|
218
|
+
for (const rule of clickRules) {
|
|
219
|
+
if (!pathMatches(rule)) continue;
|
|
220
|
+
let matched: Element | null = null;
|
|
221
|
+
try {
|
|
222
|
+
matched = target.closest(rule.selector);
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (!matched) continue;
|
|
227
|
+
const elements = Array.from(document.querySelectorAll(rule.selector));
|
|
228
|
+
emit(rule, matched, elements, {
|
|
229
|
+
viewport_x: Math.round(event.clientX),
|
|
230
|
+
viewport_y: Math.round(event.clientY),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
document.addEventListener("click", onClick, { capture: true, passive: true });
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
stop() {
|
|
239
|
+
document.removeEventListener("click", onClick, { capture: true });
|
|
240
|
+
observers.forEach((observer) => observer.disconnect());
|
|
241
|
+
for (const timer of timers.values()) window.clearTimeout(timer);
|
|
242
|
+
timers.clear();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
fetchWithoutShopifyCartMutationNotification,
|
|
5
|
+
onShopifyCartMutation,
|
|
6
|
+
} from "./shopify-cart-events";
|
|
7
|
+
|
|
8
|
+
describe("shared Shopify cart mutation events", () => {
|
|
9
|
+
let nativeFetch: ReturnType<typeof vi.fn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
nativeFetch = vi.fn(async () => new Response("original", { status: 200 }));
|
|
13
|
+
vi.stubGlobal("fetch", nativeFetch);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("installs once, supports detached calls, and restores fetch after the last cleanup", async () => {
|
|
22
|
+
const first = vi.fn();
|
|
23
|
+
const second = vi.fn();
|
|
24
|
+
const original = window.fetch;
|
|
25
|
+
const stopFirst = onShopifyCartMutation(first);
|
|
26
|
+
const wrapped = window.fetch;
|
|
27
|
+
const stopSecond = onShopifyCartMutation(second);
|
|
28
|
+
|
|
29
|
+
expect(window.fetch).toBe(wrapped);
|
|
30
|
+
const detached = window.fetch;
|
|
31
|
+
const response = await detached("/cart/add.js", { method: "POST" });
|
|
32
|
+
expect(await response.text()).toBe("original");
|
|
33
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
35
|
+
|
|
36
|
+
stopFirst();
|
|
37
|
+
expect(window.fetch).toBe(wrapped);
|
|
38
|
+
stopSecond();
|
|
39
|
+
expect(window.fetch).toBe(original);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("preserves rejected mutations and ignores failed responses", async () => {
|
|
43
|
+
const error = new Error("network failed");
|
|
44
|
+
nativeFetch
|
|
45
|
+
.mockResolvedValueOnce(new Response("failed", { status: 500 }))
|
|
46
|
+
.mockRejectedValueOnce(error);
|
|
47
|
+
const handler = vi.fn();
|
|
48
|
+
const stop = onShopifyCartMutation(handler);
|
|
49
|
+
|
|
50
|
+
await expect(window.fetch("/cart/change.js")).resolves.toMatchObject({ status: 500 });
|
|
51
|
+
await expect(window.fetch("/cart/clear.js")).rejects.toBe(error);
|
|
52
|
+
expect(handler).not.toHaveBeenCalled();
|
|
53
|
+
stop();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ignores cross-origin and suppressed Apex cart writes", async () => {
|
|
57
|
+
const handler = vi.fn();
|
|
58
|
+
const stop = onShopifyCartMutation(handler);
|
|
59
|
+
|
|
60
|
+
await window.fetch("https://other.test/cart/add.js");
|
|
61
|
+
await fetchWithoutShopifyCartMutationNotification("/cart/update.js", { method: "POST" });
|
|
62
|
+
expect(handler).not.toHaveBeenCalled();
|
|
63
|
+
stop();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("also observes classic add-to-cart form submits", () => {
|
|
67
|
+
document.body.innerHTML = '<form action="/cart/add" method="post"></form>';
|
|
68
|
+
const handler = vi.fn();
|
|
69
|
+
const stop = onShopifyCartMutation(handler);
|
|
70
|
+
document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true }));
|
|
71
|
+
return Promise.resolve().then(() => {
|
|
72
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
73
|
+
stop();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|