@affectively/aeon-pages 1.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/CHANGELOG.md +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GTM dataLayer Integration
|
|
3
|
+
*
|
|
4
|
+
* Manages the dataLayer array and provides typed event pushing.
|
|
5
|
+
* Ensures dataLayer exists and handles event formatting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AnalyticsConfig,
|
|
10
|
+
AeonEventBase,
|
|
11
|
+
ContextEvent,
|
|
12
|
+
PageViewEvent,
|
|
13
|
+
ClickEvent,
|
|
14
|
+
DataLayerEvent,
|
|
15
|
+
ESIState,
|
|
16
|
+
ElementInfo,
|
|
17
|
+
PositionInfo,
|
|
18
|
+
} from './types';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** Current analytics version */
|
|
25
|
+
export const ANALYTICS_VERSION = '1.0.0';
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// dataLayer Management
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ensure dataLayer array exists on window
|
|
33
|
+
*/
|
|
34
|
+
export function ensureDataLayer(name = 'dataLayer'): unknown[] {
|
|
35
|
+
const w = window as unknown as Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
if (!w[name]) {
|
|
38
|
+
w[name] = [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return w[name] as unknown[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Push event to dataLayer
|
|
46
|
+
*/
|
|
47
|
+
export function pushToDataLayer(
|
|
48
|
+
event: DataLayerEvent,
|
|
49
|
+
dataLayerName = 'dataLayer',
|
|
50
|
+
): void {
|
|
51
|
+
const dataLayer = ensureDataLayer(dataLayerName);
|
|
52
|
+
dataLayer.push(event);
|
|
53
|
+
|
|
54
|
+
// Debug logging
|
|
55
|
+
if (
|
|
56
|
+
(window as Window & { __AEON_ANALYTICS_DEBUG__?: boolean })
|
|
57
|
+
.__AEON_ANALYTICS_DEBUG__
|
|
58
|
+
) {
|
|
59
|
+
console.log('[Aeon Analytics]', event.event, event);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Event Builders
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create base event structure
|
|
69
|
+
*/
|
|
70
|
+
function createBaseEvent(eventName: string, prefix = 'aeon'): AeonEventBase {
|
|
71
|
+
return {
|
|
72
|
+
event: prefix ? `${prefix}.${eventName}` : eventName,
|
|
73
|
+
aeon: {
|
|
74
|
+
version: ANALYTICS_VERSION,
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build context event from ESI state
|
|
82
|
+
*/
|
|
83
|
+
export function buildContextEvent(
|
|
84
|
+
esiState: ESIState,
|
|
85
|
+
prefix = 'aeon',
|
|
86
|
+
): ContextEvent {
|
|
87
|
+
return {
|
|
88
|
+
...createBaseEvent('context', prefix),
|
|
89
|
+
event: `${prefix}.context` as 'aeon.context',
|
|
90
|
+
user: {
|
|
91
|
+
tier: esiState.userTier,
|
|
92
|
+
id: esiState.userId,
|
|
93
|
+
sessionId: esiState.sessionId,
|
|
94
|
+
isNewSession: esiState.isNewSession,
|
|
95
|
+
},
|
|
96
|
+
emotion: esiState.emotionState,
|
|
97
|
+
preferences: esiState.preferences,
|
|
98
|
+
features: esiState.features,
|
|
99
|
+
device: {
|
|
100
|
+
viewport: esiState.viewport,
|
|
101
|
+
connection: esiState.connection,
|
|
102
|
+
},
|
|
103
|
+
time: {
|
|
104
|
+
localHour: esiState.localHour,
|
|
105
|
+
timezone: esiState.timezone,
|
|
106
|
+
},
|
|
107
|
+
recentPages: esiState.recentPages,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build page view event
|
|
113
|
+
*/
|
|
114
|
+
export function buildPageViewEvent(
|
|
115
|
+
path: string,
|
|
116
|
+
title: string,
|
|
117
|
+
merkleRoot: string,
|
|
118
|
+
esiState: ESIState,
|
|
119
|
+
prefix = 'aeon',
|
|
120
|
+
): PageViewEvent {
|
|
121
|
+
return {
|
|
122
|
+
...createBaseEvent('pageview', prefix),
|
|
123
|
+
event: `${prefix}.pageview` as 'aeon.pageview',
|
|
124
|
+
page: {
|
|
125
|
+
path,
|
|
126
|
+
title,
|
|
127
|
+
merkleRoot,
|
|
128
|
+
},
|
|
129
|
+
user: {
|
|
130
|
+
tier: esiState.userTier,
|
|
131
|
+
id: esiState.userId,
|
|
132
|
+
sessionId: esiState.sessionId,
|
|
133
|
+
isNewSession: esiState.isNewSession,
|
|
134
|
+
},
|
|
135
|
+
emotion: esiState.emotionState,
|
|
136
|
+
features: esiState.features,
|
|
137
|
+
device: {
|
|
138
|
+
viewport: esiState.viewport,
|
|
139
|
+
connection: esiState.connection,
|
|
140
|
+
reducedMotion: esiState.preferences.reducedMotion,
|
|
141
|
+
},
|
|
142
|
+
time: {
|
|
143
|
+
localHour: esiState.localHour,
|
|
144
|
+
timezone: esiState.timezone,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build click event
|
|
151
|
+
*/
|
|
152
|
+
export function buildClickEvent(
|
|
153
|
+
merkleHash: string,
|
|
154
|
+
treePath: string[],
|
|
155
|
+
treePathHashes: string[],
|
|
156
|
+
element: ElementInfo,
|
|
157
|
+
position: PositionInfo,
|
|
158
|
+
context: Partial<ESIState>,
|
|
159
|
+
prefix = 'aeon',
|
|
160
|
+
): ClickEvent {
|
|
161
|
+
return {
|
|
162
|
+
...createBaseEvent('click', prefix),
|
|
163
|
+
event: `${prefix}.click` as 'aeon.click',
|
|
164
|
+
click: {
|
|
165
|
+
merkleHash,
|
|
166
|
+
treePath,
|
|
167
|
+
treePathHashes,
|
|
168
|
+
element,
|
|
169
|
+
position,
|
|
170
|
+
},
|
|
171
|
+
context,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Element Info Extraction
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract element info for tracking
|
|
181
|
+
*/
|
|
182
|
+
export function extractElementInfo(
|
|
183
|
+
element: HTMLElement,
|
|
184
|
+
maxTextLength = 100,
|
|
185
|
+
): ElementInfo {
|
|
186
|
+
// Get text content, truncated
|
|
187
|
+
let text = element.innerText || element.textContent || '';
|
|
188
|
+
if (text.length > maxTextLength) {
|
|
189
|
+
text = text.slice(0, maxTextLength) + '...';
|
|
190
|
+
}
|
|
191
|
+
// Clean whitespace
|
|
192
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
tagName: element.tagName,
|
|
196
|
+
text,
|
|
197
|
+
ariaLabel: element.getAttribute('aria-label') || undefined,
|
|
198
|
+
role: element.getAttribute('role') || undefined,
|
|
199
|
+
href: (element as HTMLAnchorElement).href || undefined,
|
|
200
|
+
id: element.id || undefined,
|
|
201
|
+
className: element.className || undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Extract position info from mouse event
|
|
207
|
+
*/
|
|
208
|
+
export function extractPositionInfo(event: MouseEvent): PositionInfo {
|
|
209
|
+
return {
|
|
210
|
+
x: event.pageX,
|
|
211
|
+
y: event.pageY,
|
|
212
|
+
viewportX: event.clientX,
|
|
213
|
+
viewportY: event.clientY,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Push Helpers
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Push context event to dataLayer
|
|
223
|
+
*/
|
|
224
|
+
export function pushContextEvent(
|
|
225
|
+
esiState: ESIState,
|
|
226
|
+
config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
227
|
+
): void {
|
|
228
|
+
const event = buildContextEvent(esiState, config.eventPrefix);
|
|
229
|
+
pushToDataLayer(event, config.dataLayerName);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Push page view event to dataLayer
|
|
234
|
+
*/
|
|
235
|
+
export function pushPageViewEvent(
|
|
236
|
+
path: string,
|
|
237
|
+
title: string,
|
|
238
|
+
merkleRoot: string,
|
|
239
|
+
esiState: ESIState,
|
|
240
|
+
config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
241
|
+
): void {
|
|
242
|
+
const event = buildPageViewEvent(
|
|
243
|
+
path,
|
|
244
|
+
title,
|
|
245
|
+
merkleRoot,
|
|
246
|
+
esiState,
|
|
247
|
+
config.eventPrefix,
|
|
248
|
+
);
|
|
249
|
+
pushToDataLayer(event, config.dataLayerName);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Push click event to dataLayer
|
|
254
|
+
*/
|
|
255
|
+
export function pushClickEvent(
|
|
256
|
+
merkleHash: string,
|
|
257
|
+
treePath: string[],
|
|
258
|
+
treePathHashes: string[],
|
|
259
|
+
element: ElementInfo,
|
|
260
|
+
position: PositionInfo,
|
|
261
|
+
context: Partial<ESIState>,
|
|
262
|
+
config: Pick<AnalyticsConfig, 'dataLayerName' | 'eventPrefix'>,
|
|
263
|
+
): void {
|
|
264
|
+
const event = buildClickEvent(
|
|
265
|
+
merkleHash,
|
|
266
|
+
treePath,
|
|
267
|
+
treePathHashes,
|
|
268
|
+
element,
|
|
269
|
+
position,
|
|
270
|
+
context,
|
|
271
|
+
config.eventPrefix,
|
|
272
|
+
);
|
|
273
|
+
pushToDataLayer(event, config.dataLayerName);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Debug Mode
|
|
278
|
+
// ============================================================================
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Enable/disable debug logging
|
|
282
|
+
*/
|
|
283
|
+
export function setDebugMode(enabled: boolean): void {
|
|
284
|
+
(
|
|
285
|
+
window as Window & { __AEON_ANALYTICS_DEBUG__?: boolean }
|
|
286
|
+
).__AEON_ANALYTICS_DEBUG__ = enabled;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get current dataLayer contents (for debugging)
|
|
291
|
+
*/
|
|
292
|
+
export function getDataLayer(name = 'dataLayer'): unknown[] {
|
|
293
|
+
return ensureDataLayer(name);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Clear dataLayer (for testing)
|
|
298
|
+
*/
|
|
299
|
+
export function clearDataLayer(name = 'dataLayer'): void {
|
|
300
|
+
const w = window as unknown as Record<string, unknown>;
|
|
301
|
+
w[name] = [];
|
|
302
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GTM Script Loader
|
|
3
|
+
*
|
|
4
|
+
* Injects Google Tag Manager script and noscript iframe.
|
|
5
|
+
* Handles async loading and ensures proper initialization order.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GTMConfig } from './types';
|
|
9
|
+
import { ensureDataLayer } from './data-layer';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// GTM Script Injection
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/** Track if GTM has been injected */
|
|
16
|
+
let gtmInjected = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate GTM script inline code
|
|
20
|
+
*/
|
|
21
|
+
function generateGTMScript(containerId: string, dataLayerName: string): string {
|
|
22
|
+
return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
23
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
24
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
25
|
+
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
|
26
|
+
})(window,document,'script','${dataLayerName}','${containerId}');`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate GTM container ID format
|
|
31
|
+
*/
|
|
32
|
+
function validateContainerId(containerId: string): boolean {
|
|
33
|
+
// GTM container ID format: GTM-XXXXXX (6-8 alphanumeric characters)
|
|
34
|
+
return /^GTM-[A-Z0-9]{6,8}$/i.test(containerId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inject GTM script into document head
|
|
39
|
+
*/
|
|
40
|
+
export function injectGTM(config: GTMConfig): boolean {
|
|
41
|
+
// Prevent double injection
|
|
42
|
+
if (gtmInjected) {
|
|
43
|
+
console.warn('[Aeon Analytics] GTM already injected');
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Validate container ID
|
|
48
|
+
if (!validateContainerId(config.containerId)) {
|
|
49
|
+
console.error(
|
|
50
|
+
`[Aeon Analytics] Invalid GTM container ID: ${config.containerId}. ` +
|
|
51
|
+
'Expected format: GTM-XXXXXX',
|
|
52
|
+
);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ensure dataLayer exists before GTM loads
|
|
57
|
+
const dataLayerName = config.dataLayerName || 'dataLayer';
|
|
58
|
+
ensureDataLayer(dataLayerName);
|
|
59
|
+
|
|
60
|
+
// Create and inject script element
|
|
61
|
+
const script = document.createElement('script');
|
|
62
|
+
script.innerHTML = generateGTMScript(config.containerId, dataLayerName);
|
|
63
|
+
|
|
64
|
+
// Insert at beginning of head for earliest execution
|
|
65
|
+
if (document.head.firstChild) {
|
|
66
|
+
document.head.insertBefore(script, document.head.firstChild);
|
|
67
|
+
} else {
|
|
68
|
+
document.head.appendChild(script);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
gtmInjected = true;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Inject GTM noscript iframe into document body
|
|
77
|
+
* For tracking when JavaScript is disabled
|
|
78
|
+
*/
|
|
79
|
+
export function injectGTMNoScript(containerId: string): boolean {
|
|
80
|
+
// Validate container ID
|
|
81
|
+
if (!validateContainerId(containerId)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if noscript already exists
|
|
86
|
+
const existingNoscript = document.querySelector(
|
|
87
|
+
`noscript iframe[src*="googletagmanager.com/ns.html?id=${containerId}"]`,
|
|
88
|
+
);
|
|
89
|
+
if (existingNoscript) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Create noscript element with iframe
|
|
94
|
+
const noscript = document.createElement('noscript');
|
|
95
|
+
const iframe = document.createElement('iframe');
|
|
96
|
+
|
|
97
|
+
iframe.src = `https://www.googletagmanager.com/ns.html?id=${containerId}`;
|
|
98
|
+
iframe.height = '0';
|
|
99
|
+
iframe.width = '0';
|
|
100
|
+
iframe.style.display = 'none';
|
|
101
|
+
iframe.style.visibility = 'hidden';
|
|
102
|
+
|
|
103
|
+
noscript.appendChild(iframe);
|
|
104
|
+
|
|
105
|
+
// Insert at beginning of body
|
|
106
|
+
if (document.body.firstChild) {
|
|
107
|
+
document.body.insertBefore(noscript, document.body.firstChild);
|
|
108
|
+
} else {
|
|
109
|
+
document.body.appendChild(noscript);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Initialize GTM with full configuration
|
|
117
|
+
*/
|
|
118
|
+
export function initializeGTM(config: GTMConfig): boolean {
|
|
119
|
+
const scriptInjected = injectGTM(config);
|
|
120
|
+
|
|
121
|
+
// Only inject noscript if script was successfully injected
|
|
122
|
+
if (scriptInjected) {
|
|
123
|
+
injectGTMNoScript(config.containerId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return scriptInjected;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Server-Side Rendering Helpers
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate GTM script tag for SSR
|
|
135
|
+
* Returns HTML string to inject into <head>
|
|
136
|
+
*/
|
|
137
|
+
export function generateGTMScriptTag(config: GTMConfig): string {
|
|
138
|
+
if (!validateContainerId(config.containerId)) {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const dataLayerName = config.dataLayerName || 'dataLayer';
|
|
143
|
+
|
|
144
|
+
return `<script>${generateGTMScript(config.containerId, dataLayerName)}</script>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generate GTM noscript tag for SSR
|
|
149
|
+
* Returns HTML string to inject at start of <body>
|
|
150
|
+
*/
|
|
151
|
+
export function generateGTMNoScriptTag(containerId: string): string {
|
|
152
|
+
if (!validateContainerId(containerId)) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${containerId}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generate dataLayer initialization script for SSR
|
|
161
|
+
* Use this before GTM script to pre-populate data
|
|
162
|
+
*/
|
|
163
|
+
export function generateDataLayerScript(
|
|
164
|
+
initialData: Record<string, unknown>,
|
|
165
|
+
dataLayerName = 'dataLayer',
|
|
166
|
+
): string {
|
|
167
|
+
const dataJson = JSON.stringify(initialData);
|
|
168
|
+
return `<script>window.${dataLayerName}=window.${dataLayerName}||[];window.${dataLayerName}.push(${dataJson});</script>`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// Status Helpers
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if GTM has been injected
|
|
177
|
+
*/
|
|
178
|
+
export function isGTMInjected(): boolean {
|
|
179
|
+
return gtmInjected;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if GTM is loaded and ready
|
|
184
|
+
*/
|
|
185
|
+
export function isGTMReady(): boolean {
|
|
186
|
+
if (!gtmInjected) return false;
|
|
187
|
+
|
|
188
|
+
// Check for gtm.js event in dataLayer
|
|
189
|
+
const dataLayer = (window as Window & { dataLayer?: unknown[] }).dataLayer;
|
|
190
|
+
if (!dataLayer) return false;
|
|
191
|
+
|
|
192
|
+
return dataLayer.some(
|
|
193
|
+
(item) =>
|
|
194
|
+
typeof item === 'object' &&
|
|
195
|
+
item !== null &&
|
|
196
|
+
(item as Record<string, unknown>).event === 'gtm.js',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Wait for GTM to be ready
|
|
202
|
+
*/
|
|
203
|
+
export function waitForGTM(timeout = 5000): Promise<boolean> {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
if (isGTMReady()) {
|
|
206
|
+
resolve(true);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const startTime = Date.now();
|
|
211
|
+
|
|
212
|
+
const check = () => {
|
|
213
|
+
if (isGTMReady()) {
|
|
214
|
+
resolve(true);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (Date.now() - startTime > timeout) {
|
|
219
|
+
resolve(false);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
requestAnimationFrame(check);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
check();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// Reset (for testing)
|
|
232
|
+
// ============================================================================
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reset GTM injection state (for testing only)
|
|
236
|
+
*/
|
|
237
|
+
export function resetGTMState(): void {
|
|
238
|
+
gtmInjected = false;
|
|
239
|
+
}
|