@technoapple/ga4 1.0.3 → 1.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.
Files changed (76) hide show
  1. package/.github/workflows/node.js.yml +31 -31
  2. package/.prettierignore +1 -1
  3. package/LICENSE +21 -21
  4. package/README.md +386 -48
  5. package/REQUIREMENTS.md +548 -0
  6. package/babel.config.js +5 -5
  7. package/build/main/dataLayer.d.ts +1 -1
  8. package/build/main/dataLayer.js +60 -10
  9. package/build/main/ga4/ga4.d.ts +13 -0
  10. package/build/main/ga4/ga4.js +24 -1
  11. package/build/main/helpers/debounce.d.ts +5 -0
  12. package/build/main/helpers/debounce.js +23 -0
  13. package/build/main/helpers/delegate.d.ts +8 -0
  14. package/build/main/helpers/delegate.js +37 -0
  15. package/build/main/helpers/dom-ready.d.ts +1 -0
  16. package/build/main/helpers/dom-ready.js +13 -0
  17. package/build/main/helpers/parse-url.d.ts +11 -0
  18. package/build/main/helpers/parse-url.js +32 -0
  19. package/build/main/helpers/session.d.ts +4 -0
  20. package/build/main/helpers/session.js +50 -0
  21. package/build/main/index.d.ts +9 -0
  22. package/build/main/index.js +19 -2
  23. package/build/main/plugins/clean-url-tracker.d.ts +17 -0
  24. package/build/main/plugins/clean-url-tracker.js +105 -0
  25. package/build/main/plugins/event-tracker.d.ts +27 -0
  26. package/build/main/plugins/event-tracker.js +76 -0
  27. package/build/main/plugins/impression-tracker.d.ts +32 -0
  28. package/build/main/plugins/impression-tracker.js +202 -0
  29. package/build/main/plugins/index.d.ts +8 -0
  30. package/build/main/plugins/index.js +20 -0
  31. package/build/main/plugins/media-query-tracker.d.ts +20 -0
  32. package/build/main/plugins/media-query-tracker.js +96 -0
  33. package/build/main/plugins/outbound-form-tracker.d.ts +17 -0
  34. package/build/main/plugins/outbound-form-tracker.js +55 -0
  35. package/build/main/plugins/outbound-link-tracker.d.ts +19 -0
  36. package/build/main/plugins/outbound-link-tracker.js +63 -0
  37. package/build/main/plugins/page-visibility-tracker.d.ts +24 -0
  38. package/build/main/plugins/page-visibility-tracker.js +93 -0
  39. package/build/main/plugins/url-change-tracker.d.ts +20 -0
  40. package/build/main/plugins/url-change-tracker.js +76 -0
  41. package/build/main/types/plugins.d.ts +78 -0
  42. package/build/main/types/plugins.js +3 -0
  43. package/build/tsconfig.tsbuildinfo +1 -1
  44. package/docs/examples/react.md +95 -0
  45. package/docs/examples/vanilla.md +65 -0
  46. package/docs/examples/vue.md +87 -0
  47. package/jest.config.ts +195 -195
  48. package/package.json +56 -52
  49. package/src/dataLayer.ts +85 -23
  50. package/src/ga4/ga4.ts +69 -40
  51. package/src/ga4/ga4option.ts +4 -4
  52. package/src/ga4/index.ts +4 -4
  53. package/src/helpers/debounce.ts +28 -0
  54. package/src/helpers/delegate.ts +51 -0
  55. package/src/helpers/dom-ready.ts +7 -0
  56. package/src/helpers/parse-url.ts +37 -0
  57. package/src/helpers/session.ts +39 -0
  58. package/src/index.ts +34 -7
  59. package/src/plugins/clean-url-tracker.ts +112 -0
  60. package/src/plugins/event-tracker.ts +90 -0
  61. package/src/plugins/impression-tracker.ts +230 -0
  62. package/src/plugins/index.ts +8 -0
  63. package/src/plugins/media-query-tracker.ts +116 -0
  64. package/src/plugins/outbound-form-tracker.ts +65 -0
  65. package/src/plugins/outbound-link-tracker.ts +72 -0
  66. package/src/plugins/page-visibility-tracker.ts +104 -0
  67. package/src/plugins/url-change-tracker.ts +84 -0
  68. package/src/types/dataLayer.ts +9 -9
  69. package/src/types/global.ts +12 -12
  70. package/src/types/gtag.ts +259 -259
  71. package/src/types/plugins.ts +98 -0
  72. package/src/util.ts +18 -18
  73. package/test/dataLayer.spec.ts +55 -40
  74. package/test/ga4.spec.ts +36 -36
  75. package/tsconfig.json +28 -28
  76. package/tsconfig.module.json +11 -11
@@ -0,0 +1,548 @@
1
+ # Project Requirements — @technoapple/ga4 v2.0
2
+
3
+ > **Version:** 2.0
4
+ > **Last Updated:** 2026-02-24
5
+ > **Author:** keke78ui9
6
+ > **Status:** Draft
7
+ > **Reference:** [googleanalytics/autotrack](https://github.com/googleanalytics/autotrack) — tracking concepts adapted for GA4
8
+
9
+ ---
10
+
11
+ ## 1. Overview
12
+
13
+ **Project Name:** @technoapple/ga4
14
+ **Description:** A TypeScript library that provides functions to support sending GA4 events, interacting with `window.dataLayer`, and **automatic tracking plugins** — providing enhanced tracking capabilities beyond GA4's built-in enhanced measurement.
15
+
16
+ **Goals:**
17
+ - Provide automatic tracking plugins for GA4 via `gtag()` / `dataLayer`
18
+ - Maintain the existing `ga4.init()`, `ga4.send()`, `ga4.gtag`, and `dataLayerHelper.get()` APIs
19
+ - Provide each plugin as an opt-in module (tree-shakeable) so consumers only pay for what they use
20
+ - Written in TypeScript with full type safety, following the existing codebase patterns
21
+ - Zero third-party runtime dependencies (browser APIs only)
22
+
23
+ **Out of Scope:**
24
+ - Server-side tracking / Measurement Protocol
25
+ - Google Tag Manager container management
26
+
27
+ ---
28
+
29
+ ## 2. Background & Motivation
30
+
31
+ GA4's built-in enhanced measurement covers some basic automatic tracking (page views, scroll to 90%, outbound clicks, site search, video engagement, file downloads). However, many advanced tracking scenarios are not covered:
32
+
33
+ - **Declarative event tracking** via HTML attributes (no JS needed)
34
+ - **Granular scroll depth** tracking is limited — GA4 only fires a single event at 90%
35
+ - **Page visibility** duration (time in foreground vs. background tab)
36
+ - **SPA URL changes** require manual setup in many frameworks
37
+ - **Element impression** tracking (ads, CTAs entering the viewport)
38
+ - **Outbound form** submit tracking
39
+ - **URL normalization** to prevent fragmented reporting
40
+ - **Media query / breakpoint** change tracking
41
+
42
+ This library provides these capabilities as TypeScript plugins that integrate with GA4 via `gtag('event', ...)`.
43
+
44
+ > **Note:** GA4 enhanced measurement already tracks scroll events (at 90% depth). This library does **not** duplicate that — instead it focuses on capabilities GA4 does not provide out of the box.
45
+
46
+ ---
47
+
48
+ ## 3. Functional Requirements — Existing Features (Already Implemented)
49
+
50
+ | ID | Requirement | Priority | Status |
51
+ |--------|-------------------------------------|----------|-----------|
52
+ | FR-001 | GA4 initialization via `ga4.init()` | High | ✅ Done |
53
+ | FR-002 | Send events via `ga4.send()` | High | ✅ Done |
54
+ | FR-003 | Direct `gtag()` access | High | ✅ Done |
55
+ | FR-004 | Read values from `dataLayer` | Medium | ✅ Done |
56
+
57
+ ---
58
+
59
+ ## 4. Functional Requirements — New Plugins
60
+
61
+ ### 4.1 Plugin Summary
62
+
63
+ | # | Plugin | Priority | Rationale |
64
+ |---|--------------------------|----------|-----------|
65
+ | 1 | `eventTracker` | High | Declarative event tracking via HTML `data-*` attributes. No JS needed for page authors. |
66
+ | 2 | `outboundLinkTracker` | High | Click delegation on `<a>` elements, compare hostnames. Uses `navigator.sendBeacon` for reliability. |
67
+ | 3 | `outboundFormTracker` | Medium | Submit delegation on `<form>` elements with external `action` URLs. |
68
+ | 4 | `pageVisibilityTracker` | High | `document.visibilitychange` API. Track visible/hidden time. Handle session timeout. |
69
+ | 5 | `urlChangeTracker` | High | `popstate` + monkey-patch `history.pushState`/`replaceState` for SPA `page_view` tracking. |
70
+ | 6 | `impressionTracker` | Medium | `IntersectionObserver` + `MutationObserver`. Track element visibility in viewport. |
71
+ | 7 | `cleanUrlTracker` | Medium | Normalize URLs before sending `page_view` (strip query params, trailing slashes, force lowercase). |
72
+ | 8 | `mediaQueryTracker` | Low | `window.matchMedia` API. Track responsive breakpoint changes. |
73
+
74
+ ### 4.2 Detailed Plugin Requirements
75
+
76
+ ---
77
+
78
+ #### FR-100: `eventTracker` — Declarative Event Tracking via HTML Attributes
79
+
80
+ **Description:**
81
+ Allow page authors to track user interactions by adding `data-ga4-*` attributes to HTML elements, without writing JavaScript. The plugin listens for DOM events (click, submit, etc.) on elements matching a configurable selector and sends GA4 events based on attribute values.
82
+
83
+ **Options:**
84
+
85
+ | Option | Type | Default | Description |
86
+ |--------|------|---------|-------------|
87
+ | `events` | `string[]` | `['click']` | DOM event types to listen for |
88
+ | `attributePrefix` | `string` | `'data-ga4-'` | Prefix for data attributes |
89
+ | `hitFilter` | `(params, element, event) => params \| null` | `undefined` | Filter/modify params before sending |
90
+
91
+ **HTML Example:**
92
+ ```html
93
+ <button
94
+ data-ga4-on="click"
95
+ data-ga4-event-name="video_play"
96
+ data-ga4-video-title="My Video"
97
+ data-ga4-video-id="abc123">
98
+ Play video
99
+ </button>
100
+ ```
101
+
102
+ **Sent as:**
103
+ ```js
104
+ gtag('event', 'video_play', { video_title: 'My Video', video_id: 'abc123' });
105
+ ```
106
+
107
+ **Acceptance Criteria:**
108
+ - [ ] Reads event name from `data-ga4-event-name` attribute
109
+ - [ ] Reads all `data-ga4-*` attributes as event parameters (kebab-case → snake_case)
110
+ - [ ] Supports configurable event types (`click`, `submit`, `change`, etc.)
111
+ - [ ] Uses event delegation on `document` for performance
112
+ - [ ] Provides `remove()` method to clean up listeners
113
+ - [ ] Does not throw errors when attributes are missing
114
+
115
+ **Implementation Notes:**
116
+ - Use event delegation (single listener on `document`) for performance
117
+ - Convert `data-ga4-video-title` → `video_title` parameter name
118
+ - Implement lightweight internal `delegate()` utility (zero dependencies)
119
+
120
+ ---
121
+
122
+ #### FR-101: `outboundLinkTracker` — Automatic Outbound Link Click Tracking
123
+
124
+ **Description:**
125
+ Automatically detect when a user clicks a link pointing to an external domain and send a GA4 event.
126
+
127
+ **Options:**
128
+
129
+ | Option | Type | Default | Description |
130
+ |--------|------|---------|-------------|
131
+ | `events` | `string[]` | `['click']` | DOM events to listen for (e.g. add `'auxclick'`, `'contextmenu'`) |
132
+ | `linkSelector` | `string` | `'a, area'` | CSS selector for link elements |
133
+ | `shouldTrackOutboundLink` | `(link: HTMLAnchorElement, parseUrl: Function) => boolean` | hostname !== location.hostname | Customize outbound detection |
134
+ | `eventName` | `string` | `'outbound_link_click'` | GA4 event name |
135
+ | `attributePrefix` | `string` | `'data-ga4-'` | Prefix for declarative attribute overrides |
136
+ | `hitFilter` | `Function` | `undefined` | Filter/modify params before sending |
137
+
138
+ **Default event parameters sent:**
139
+
140
+ | Parameter | Value |
141
+ |-----------|-------|
142
+ | `event_name` | `'outbound_link_click'` |
143
+ | `link_url` | Full href of the clicked link |
144
+ | `link_domain` | Hostname of the outbound link |
145
+ | `outbound` | `true` |
146
+
147
+ **Acceptance Criteria:**
148
+ - [ ] Detects clicks on `<a>` and `<area>` elements pointing to external domains
149
+ - [ ] Uses `navigator.sendBeacon` transport for reliability (page may unload)
150
+ - [ ] Supports right-click and middle-click tracking via `events` option
151
+ - [ ] Provides `shouldTrackOutboundLink` callback for custom domain logic
152
+ - [ ] Provides `remove()` method to clean up all event listeners
153
+ - [ ] Handles links with `xlink:href` (SVG links)
154
+
155
+ ---
156
+
157
+ #### FR-102: `outboundFormTracker` — Automatic Outbound Form Submit Tracking
158
+
159
+ **Description:**
160
+ Automatically detect when a form is submitted to an external domain and send a GA4 event.
161
+
162
+ **Options:**
163
+
164
+ | Option | Type | Default | Description |
165
+ |--------|------|---------|-------------|
166
+ | `formSelector` | `string` | `'form'` | CSS selector for forms |
167
+ | `shouldTrackOutboundForm` | `(form: HTMLFormElement, parseUrl: Function) => boolean` | action hostname !== location.hostname | Custom detection |
168
+ | `eventName` | `string` | `'outbound_form_submit'` | GA4 event name |
169
+ | `hitFilter` | `Function` | `undefined` | Filter/modify params |
170
+
171
+ **Acceptance Criteria:**
172
+ - [ ] Detects form submits where `form.action` points to an external domain
173
+ - [ ] Delays form submission briefly to ensure the GA4 event is sent
174
+ - [ ] Falls back gracefully if `navigator.sendBeacon` is not available
175
+ - [ ] Provides `remove()` method
176
+
177
+ ---
178
+
179
+ #### FR-103: `pageVisibilityTracker` — Page Visibility Duration Tracking
180
+
181
+ **Description:**
182
+ Track how long a page is in the visible state vs. hidden (background tab). Optionally send a new `page_view` when the page becomes visible again after session timeout.
183
+
184
+ **Options:**
185
+
186
+ | Option | Type | Default | Description |
187
+ |--------|------|---------|-------------|
188
+ | `sendInitialPageview` | `boolean` | `false` | Plugin handles the initial page_view |
189
+ | `sessionTimeout` | `number` | `30` (minutes) | Minutes of hidden time before new session |
190
+ | `timeZone` | `string` | `undefined` | IANA timezone for session boundary |
191
+ | `pageLoadsMetricIndex` | `number` | `undefined` | Custom metric index |
192
+ | `visibleMetricIndex` | `number` | `undefined` | Custom metric for visible time |
193
+ | `eventName` | `string` | `'page_visibility'` | GA4 event name |
194
+ | `hitFilter` | `Function` | `undefined` | Filter/modify params |
195
+
196
+ **Default event parameters sent:**
197
+
198
+ | Parameter | Value |
199
+ |-----------|-------|
200
+ | `event_name` | `'page_visibility'` |
201
+ | `visibility_state` | `'visible'` or `'hidden'` |
202
+ | `visibility_duration` | Time in ms the page was in previous state |
203
+ | `page_path` | Current page path |
204
+
205
+ **Acceptance Criteria:**
206
+ - [ ] Listens for `visibilitychange` events on `document`
207
+ - [ ] Tracks cumulative visible time accurately
208
+ - [ ] Optionally sends new `page_view` on visible→hidden→visible session timeout
209
+ - [ ] Sends final visibility duration on `beforeunload`
210
+ - [ ] Provides `remove()` method
211
+
212
+ ---
213
+
214
+ #### FR-104: `urlChangeTracker` — SPA URL Change Tracking
215
+
216
+ **Description:**
217
+ Automatically track URL changes in Single Page Applications by intercepting `history.pushState()`, `history.replaceState()`, and `popstate` events, sending a `page_view` event for each navigation.
218
+
219
+ **Options:**
220
+
221
+ | Option | Type | Default | Description |
222
+ |--------|------|---------|-------------|
223
+ | `shouldTrackUrlChange` | `(newPath: string, oldPath: string) => boolean` | `newPath !== oldPath` | Custom logic for what counts as a URL change |
224
+ | `trackReplaceState` | `boolean` | `false` | Whether `replaceState` triggers tracking |
225
+ | `hitFilter` | `Function` | `undefined` | Filter/modify params |
226
+
227
+ **Default event parameters sent:**
228
+
229
+ | Parameter | Value |
230
+ |-----------|-------|
231
+ | `event_name` | `'page_view'` |
232
+ | `page_path` | New URL path |
233
+ | `page_title` | `document.title` |
234
+ | `page_location` | Full URL |
235
+
236
+ **Acceptance Criteria:**
237
+ - [ ] Monkey-patches `history.pushState` and optionally `history.replaceState`
238
+ - [ ] Listens for `popstate` events (back/forward navigation)
239
+ - [ ] Sends `page_view` GA4 event on each tracked URL change
240
+ - [ ] Provides `shouldTrackUrlChange` callback for filtering
241
+ - [ ] Restores original `history.pushState`/`replaceState` on `remove()`
242
+ - [ ] Does not double-fire for the initial page load
243
+
244
+ ---
245
+
246
+ #### FR-105: `impressionTracker` — Element Viewport Impression Tracking
247
+
248
+ **Description:**
249
+ Track when specific DOM elements become visible in the viewport using `IntersectionObserver`. Useful for tracking ad impressions, CTA visibility, etc.
250
+
251
+ **Options:**
252
+
253
+ | Option | Type | Default | Description |
254
+ |--------|------|---------|-------------|
255
+ | `elements` | `Array<string \| ElementConfig>` | `[]` | Element IDs or config objects to observe |
256
+ | `rootMargin` | `string` | `'0px'` | IntersectionObserver rootMargin |
257
+ | `attributePrefix` | `string` | `'data-ga4-'` | Attribute prefix for declarative params |
258
+ | `eventName` | `string` | `'element_impression'` | GA4 event name |
259
+ | `hitFilter` | `Function` | `undefined` | Filter/modify params |
260
+
261
+ **Element config object:**
262
+
263
+ | Property | Type | Default | Description |
264
+ |----------|------|---------|-------------|
265
+ | `id` | `string` | — | Element ID to observe |
266
+ | `threshold` | `number` | `0` | Visibility ratio (0-1) to trigger |
267
+ | `trackFirstImpressionOnly` | `boolean` | `true` | Only fire once per element |
268
+
269
+ **Acceptance Criteria:**
270
+ - [ ] Uses `IntersectionObserver` API to detect element visibility
271
+ - [ ] Uses `MutationObserver` to handle dynamically added/removed elements
272
+ - [ ] Supports per-element threshold configuration
273
+ - [ ] Supports `trackFirstImpressionOnly` option
274
+ - [ ] Provides `observeElements()`, `unobserveElements()`, `unobserveAllElements()` methods
275
+ - [ ] Feature-detects `IntersectionObserver` / `MutationObserver` — no-ops gracefully if unsupported
276
+ - [ ] Provides `remove()` method
277
+
278
+ ---
279
+
280
+ #### FR-106: `cleanUrlTracker` — URL Normalization for page_view Events
281
+
282
+ **Description:**
283
+ Normalize URLs before they are sent with `page_view` events to ensure consistency in GA4 reports.
284
+
285
+ **Options:**
286
+
287
+ | Option | Type | Default | Description |
288
+ |--------|------|---------|-------------|
289
+ | `stripQuery` | `boolean` | `false` | Remove query string from URLs |
290
+ | `queryParamsAllowlist` | `string[]` | `undefined` | Query params to keep (when `stripQuery` is true) |
291
+ | `queryParamsDenylist` | `string[]` | `undefined` | Specific query params to remove |
292
+ | `trailingSlash` | `'add' \| 'remove'` | `undefined` | Normalize trailing slashes |
293
+ | `urlFilter` | `(url: string) => string` | `undefined` | Custom URL transformation function |
294
+
295
+ **Acceptance Criteria:**
296
+ - [ ] Strips query parameters when configured
297
+ - [ ] Supports allowlist/denylist for selective query param removal
298
+ - [ ] Normalizes trailing slashes
299
+ - [ ] Applies custom `urlFilter` function
300
+ - [ ] Applies transformations to `page_location` and `page_path` in page_view events
301
+ - [ ] Provides `remove()` method
302
+
303
+ ---
304
+
305
+ #### FR-107: `mediaQueryTracker` — Responsive Breakpoint Tracking
306
+
307
+ **Description:**
308
+ Track which CSS media query breakpoints match and fire events when breakpoints change.
309
+
310
+ **Options:**
311
+
312
+ | Option | Type | Default | Description |
313
+ |--------|------|---------|-------------|
314
+ | `definitions` | `MediaQueryDefinition[]` | `[]` | Array of media query definitions |
315
+ | `changeTemplate` | `(oldValue: string, newValue: string) => string` | `'${oldValue} => ${newValue}'` | Template for change events |
316
+ | `changeTimeout` | `number` | `1000` | Debounce timeout in ms |
317
+ | `eventName` | `string` | `'media_query_change'` | GA4 event name |
318
+ | `hitFilter` | `Function` | `undefined` | Filter/modify params |
319
+
320
+ **MediaQueryDefinition:**
321
+
322
+ | Property | Type | Description |
323
+ |----------|------|-------------|
324
+ | `name` | `string` | e.g. `'Breakpoint'` |
325
+ | `dimensionIndex` | `number` | Custom dimension index |
326
+ | `items` | `Array<{name: string, media: string}>` | Media query items |
327
+
328
+ **Acceptance Criteria:**
329
+ - [ ] Uses `window.matchMedia()` API
330
+ - [ ] Fires event on breakpoint change with old and new values
331
+ - [ ] Debounces rapid changes (e.g. window resize)
332
+ - [ ] Feature-detects `matchMedia` — no-ops if unsupported
333
+ - [ ] Provides `remove()` method
334
+
335
+ ---
336
+
337
+ ## 5. Non-Functional Requirements
338
+
339
+ | ID | Requirement | Priority | Status |
340
+ |---------|--------------------------|----------|-------------|
341
+ | NFR-001 | Modern Browser Support | High | Not Started |
342
+ | NFR-002 | TypeScript Type Safety | High | Not Started |
343
+ | NFR-003 | Zero Runtime Dependencies | High | Not Started |
344
+ | NFR-004 | Tree-Shakeable Plugins | High | Not Started |
345
+ | NFR-005 | Bundle Size < 8KB gzip | Medium | Not Started |
346
+ | NFR-006 | Test Coverage >= 80% | Medium | Not Started |
347
+ | NFR-007 | Documentation per Plugin | Medium | Not Started |
348
+ | NFR-008 | Graceful Feature Detection | High | Not Started |
349
+
350
+ ### 5.1 Details
351
+
352
+ - **Browser Compatibility:** Chrome 64+, Firefox 67+, Safari 12+, Edge 79+ (all browsers supporting `IntersectionObserver`, `MutationObserver`, `navigator.sendBeacon`)
353
+ - **TypeScript Version:** >= 4.x
354
+ - **Bundle Size Target:** < 8KB gzipped (all plugins), individual plugins < 2KB each
355
+ - **Test Coverage Target:** >= 80% line coverage
356
+ - **Graceful Degradation:** All plugins must feature-detect required browser APIs and silently no-op if unsupported (never throw errors)
357
+
358
+ ---
359
+
360
+ ## 6. Technical Requirements
361
+
362
+ - **Language:** TypeScript (strict mode)
363
+ - **Build Tool:** tsc
364
+ - **Test Framework:** Jest + jsdom
365
+ - **Package Registry:** npm (public, `@technoapple/ga4`)
366
+ - **Module Format:** CommonJS + ESM (dual publish)
367
+ - **Runtime Dependencies:** None (zero dependencies)
368
+ - **Node.js Version:** >= 16
369
+
370
+ ---
371
+
372
+ ## 7. Architecture & API Design
373
+
374
+ ### 7.1 Existing APIs (unchanged)
375
+
376
+ | API | Method | Parameters | Returns | Description |
377
+ |-----|--------|------------|---------|-------------|
378
+ | `ga4` | `init` | `options: ga4Option` | `void` | Initialize GA4 with targetId |
379
+ | `ga4` | `send` | `event: string, params: KeyValueParams` | `boolean` | Send a GA4 event |
380
+ | `ga4` | `gtag` | (getter) | `gtag` | Direct access to `window.gtag` |
381
+ | `dataLayerHelper` | `get` | `key: string, getLast?: boolean` | `any` | Retrieve value from dataLayer |
382
+
383
+ ### 7.2 New Plugin Architecture
384
+
385
+ Each plugin follows a consistent pattern:
386
+
387
+ ```typescript
388
+ interface PluginInterface {
389
+ remove(): void; // Clean up all listeners, restore original state
390
+ }
391
+ ```
392
+
393
+ **Plugin registration pattern (follows existing singleton pattern):**
394
+
395
+ ```typescript
396
+ import { ga4, plugins } from '@technoapple/ga4';
397
+
398
+ // Initialize GA4 (existing)
399
+ ga4.init({ targetId: 'G-XXXXXXX' });
400
+
401
+ // Register plugins (new)
402
+ ga4.use(plugins.outboundLinkTracker, { /* options */ });
403
+ ga4.use(plugins.pageVisibilityTracker);
404
+ ga4.use(plugins.urlChangeTracker);
405
+
406
+ // Or import individual plugins directly
407
+ import { OutboundLinkTracker } from '@technoapple/ga4/plugins';
408
+ const tracker = new OutboundLinkTracker(ga4, { /* options */ });
409
+ tracker.remove(); // cleanup
410
+ ```
411
+
412
+ ### 7.3 Proposed File Structure
413
+
414
+ ```
415
+ src/
416
+ index.ts # Main exports (existing + new)
417
+ ga4/
418
+ ga4.ts # Core GA4 class (existing, add .use() method)
419
+ ga4option.ts # Options interface (existing)
420
+ index.ts # GA4 barrel export (existing)
421
+ dataLayer.ts # DataLayer helper (existing)
422
+ util.ts # Utilities (existing, extend)
423
+ types/
424
+ dataLayer.ts # DataLayer types (existing)
425
+ global.ts # Window augmentation (existing)
426
+ gtag.ts # gtag type definitions (existing)
427
+ plugins.ts # Plugin option types (new)
428
+ plugins/
429
+ index.ts # Barrel export for all plugins
430
+ plugin-base.ts # Base class / interface for plugins
431
+ event-tracker.ts # FR-100
432
+ outbound-link-tracker.ts # FR-101
433
+ outbound-form-tracker.ts # FR-102
434
+ page-visibility-tracker.ts # FR-103
435
+ url-change-tracker.ts # FR-104
436
+ impression-tracker.ts # FR-105
437
+ clean-url-tracker.ts # FR-106
438
+ media-query-tracker.ts # FR-107
439
+ helpers/
440
+ delegate.ts # Event delegation utility
441
+ parse-url.ts # URL parsing utility
442
+ dom-ready.ts # DOM ready utility
443
+ session.ts # Session timeout / storage utilities
444
+ debounce.ts # Debounce utility
445
+ test/
446
+ dataLayer.spec.ts # Existing
447
+ ga4.spec.ts # Existing
448
+ plugins/
449
+ event-tracker.spec.ts
450
+ outbound-link-tracker.spec.ts
451
+ outbound-form-tracker.spec.ts
452
+ page-visibility-tracker.spec.ts
453
+ url-change-tracker.spec.ts
454
+ impression-tracker.spec.ts
455
+ clean-url-tracker.spec.ts
456
+ media-query-tracker.spec.ts
457
+ helpers/
458
+ delegate.spec.ts
459
+ parse-url.spec.ts
460
+ session.spec.ts
461
+ ```
462
+
463
+ ### 7.4 Internal Utilities
464
+
465
+ Lightweight internal helpers to keep zero runtime dependencies:
466
+
467
+ | Utility | File | Description |
468
+ |---------|------|-------------|
469
+ | `delegate` | `helpers/delegate.ts` | Event delegation using `document.addEventListener` + selector matching |
470
+ | `parseUrl` | `helpers/parse-url.ts` | Create an `<a>` element to parse URLs (returns `Location`-like object) |
471
+ | `domReady` | `helpers/dom-ready.ts` | Wait for DOM `DOMContentLoaded` |
472
+ | `sessionManager` | `helpers/session.ts` | `sessionStorage`-based session tracking with configurable timeout |
473
+ | `debounce` | `helpers/debounce.ts` | Standard debounce implementation |
474
+
475
+ ---
476
+
477
+ ## 8. User Stories
478
+
479
+ | ID | Story | Priority |
480
+ |-------|-------|----------|
481
+ | US-01 | As a developer, I want to track outbound link clicks automatically so I can see which external sites users navigate to | High |
482
+ | US-02 | As a developer, I want to track page visibility so I can measure actual engagement time | High |
483
+ | US-03 | As a developer, I want SPA URL changes to automatically send page_view events so my GA4 reports are accurate | High |
484
+ | US-04 | As a content author, I want to add tracking via HTML data attributes without writing JavaScript | High |
485
+ | US-05 | As a developer, I want to track when specific elements (ads, CTAs) become visible in the viewport | Medium |
486
+ | US-06 | As a developer, I want to track outbound form submissions so I don't lose visibility when users are sent to external payment/signup pages | Medium |
487
+ | US-07 | As a developer, I want to normalize page URLs in my tracking to avoid fragmented data in GA4 reports | Medium |
488
+ | US-08 | As a developer, I want to track which responsive breakpoints are active so I can correlate device size with behavior | Low |
489
+ | US-09 | As a developer, I want to only import the plugins I need so my bundle size stays small | High |
490
+
491
+ ---
492
+
493
+ ## 9. Constraints & Assumptions
494
+
495
+ ### Constraints
496
+ - Must not introduce any third-party runtime dependencies
497
+ - Must work in browser environments only (`window`, `document` required)
498
+ - Must be backward compatible — existing `ga4.init()`, `ga4.send()`, `ga4.gtag`, `dataLayerHelper.get()` APIs must not change
499
+ - Each plugin must be independently importable (tree-shakeable)
500
+ - All DOM event listeners must be removable (no leaks)
501
+
502
+ ### Assumptions
503
+ - `window` and `document` are available (browser environment)
504
+ - GA4's `gtag.js` script is loaded by the consumer (or `ga4.init()` has been called)
505
+ - Consumers use modern browsers (no IE11 support needed)
506
+ - `sessionStorage` is available for session-based tracking
507
+
508
+ ---
509
+
510
+ ## 10. Risks & Mitigations
511
+
512
+ | Risk | Impact | Likelihood | Mitigation |
513
+ |------|--------|------------|------------|
514
+ | Google changes gtag.js API | High | Low | Use documented public API only; abstract `gtag()` calls through our `ga4.send()` |
515
+ | IntersectionObserver not available in older browsers | Medium | Low | Feature detection — impressionTracker silently no-ops |
516
+ | Monkey-patching `history.pushState` conflicts with other libraries (e.g. React Router) | Medium | Medium | Carefully chain the original function; test with popular routers |
517
+ | `sendBeacon` not available | Low | Low | Fallback to synchronous XHR or `_blank` target trick |
518
+ | `sessionStorage` quota exceeded or disabled | Low | Low | Wrap in try-catch, degrade gracefully |
519
+
520
+ ---
521
+
522
+ ## 11. Implementation Order & Milestones
523
+
524
+ Plugins are ordered by priority and dependency:
525
+
526
+ | Phase | Milestone | Plugins / Tasks | Target Date | Status |
527
+ |-------|-----------|-----------------|-------------|--------|
528
+ | 0 | Core infrastructure | `helpers/` utilities, plugin base class, `ga4.use()` method | TBD | Not Started |
529
+ | 1 | High-priority plugins | `eventTracker`, `outboundLinkTracker` | TBD | Not Started |
530
+ | 2 | SPA & visibility | `urlChangeTracker`, `pageVisibilityTracker` | TBD | Not Started |
531
+ | 3 | Remaining plugins | `impressionTracker`, `outboundFormTracker`, `cleanUrlTracker` | TBD | Not Started |
532
+ | 4 | Low-priority | `mediaQueryTracker` | TBD | Not Started |
533
+ | 5 | Polish | Documentation, README update, examples | TBD | Not Started |
534
+ | 6 | Release | npm publish v2.0.0 | TBD | Not Started |
535
+
536
+ ---
537
+
538
+ ## 12. Sign-Off
539
+
540
+ | Role | Name | Date | Approved |
541
+ |----------------|------|------|----------|
542
+ | Product Owner | | | [ ] |
543
+ | Tech Lead | | | [ ] |
544
+ | QA | | | [ ] |
545
+
546
+ ---
547
+
548
+ _This document is a living artifact. Update it as requirements evolve._
package/babel.config.js CHANGED
@@ -1,6 +1,6 @@
1
- module.exports = {
2
- presets: [
3
- ['@babel/preset-env', {targets: {node: 'current'}}],
4
- '@babel/preset-typescript',
5
- ],
1
+ module.exports = {
2
+ presets: [
3
+ ['@babel/preset-env', {targets: {node: 'current'}}],
4
+ '@babel/preset-typescript',
5
+ ],
6
6
  };
@@ -4,5 +4,5 @@
4
4
  * @param getLast boolean, false (default) find the first item, true search the last value for the same key
5
5
  * @returns return the value if find, otherwise return empty string;
6
6
  */
7
- declare function get(key: string, getLast?: boolean): string | object;
7
+ declare function get(key: string, getLast?: boolean): any;
8
8
  export { get };
@@ -1,7 +1,47 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.get = void 0;
4
- const util_1 = require("./util");
4
+ function isObject(target) {
5
+ if (!target) {
6
+ return false;
7
+ }
8
+ return Object.prototype.toString.call(target) === '[object Object]';
9
+ }
10
+ function isArguments(target) {
11
+ if (!target) {
12
+ return false;
13
+ }
14
+ return Object.prototype.toString.call(target) === '[object Arguments]';
15
+ }
16
+ function getDataValue(key, currentData) {
17
+ if (isObject(currentData)) {
18
+ const data = currentData[key];
19
+ if (data) {
20
+ return data;
21
+ }
22
+ return null;
23
+ }
24
+ else if (isArguments(currentData) || Array.isArray(currentData)) {
25
+ const arr = Object.values(currentData);
26
+ const data = arr.find(c => c === key);
27
+ if (data) {
28
+ return data;
29
+ }
30
+ const dataObj = arr.find(c => isObject(c));
31
+ if (dataObj) {
32
+ const data = dataObj[key];
33
+ if (data) {
34
+ return data;
35
+ }
36
+ return null;
37
+ }
38
+ return null;
39
+ }
40
+ else {
41
+ // not support.
42
+ return null;
43
+ }
44
+ }
5
45
  /**
6
46
  * get value from dataLayer
7
47
  * @param key key to search from dataLayer
@@ -9,17 +49,27 @@ const util_1 = require("./util");
9
49
  * @returns return the value if find, otherwise return empty string;
10
50
  */
11
51
  function get(key, getLast) {
12
- if (window.dataLayer) {
13
- let data = {};
14
- if (!getLast) {
15
- data = window.dataLayer?.find((item) => item[key]);
52
+ if (!window.dataLayer || !Array.isArray(window.dataLayer)) {
53
+ return '';
54
+ }
55
+ if (!getLast) {
56
+ for (let index = 0; index < window.dataLayer.length; index++) {
57
+ const data = getDataValue(key, window.dataLayer[index]);
58
+ if (!data) {
59
+ continue;
60
+ }
61
+ return data;
16
62
  }
17
- else {
18
- data = (0, util_1.findLast)(window.dataLayer, ((item) => item[key]));
63
+ }
64
+ else {
65
+ for (let index = window.dataLayer.length; index > 0; index--) {
66
+ const data = getDataValue(key, window.dataLayer[index]);
67
+ if (!data) {
68
+ continue;
69
+ }
70
+ return data;
19
71
  }
20
- return data ? data[key] : '';
21
72
  }
22
- return '';
23
73
  }
24
74
  exports.get = get;
25
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGF0YUxheWVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2RhdGFMYXllci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFBQSxpQ0FBZ0M7QUFDaEM7Ozs7O0dBS0c7QUFDSCxTQUFTLEdBQUcsQ0FBQyxHQUFVLEVBQUUsT0FBZ0I7SUFFckMsSUFBSSxNQUFNLENBQUMsU0FBUyxFQUFFO1FBQ2xCLElBQUksSUFBSSxHQUFHLEVBQVMsQ0FBQztRQUNyQixJQUFJLENBQUMsT0FBTyxFQUFFO1lBQ1YsSUFBSSxHQUFHLE1BQU0sQ0FBQyxTQUFTLEVBQUUsSUFBSSxDQUFDLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQztTQUN0RDthQUNJO1lBQ0QsSUFBSSxHQUFHLElBQUEsZUFBUSxFQUFDLE1BQU0sQ0FBQyxTQUFTLEVBQUUsQ0FBQyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLENBQUMsQ0FBQztTQUM1RDtRQUNELE9BQU8sSUFBSSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztLQUNoQztJQUVELE9BQU8sRUFBRSxDQUFDO0FBQ2QsQ0FBQztBQUVPLGtCQUFHIn0=
75
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGF0YUxheWVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2RhdGFMYXllci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFFQSxTQUFTLFFBQVEsQ0FBQyxNQUFVO0lBQ3hCLElBQUksQ0FBQyxNQUFNLEVBQUU7UUFDVCxPQUFPLEtBQUssQ0FBQztLQUNoQjtJQUVELE9BQU8sTUFBTSxDQUFDLFNBQVMsQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxLQUFLLGlCQUFpQixDQUFDO0FBQ3hFLENBQUM7QUFFRCxTQUFTLFdBQVcsQ0FBQyxNQUFhO0lBQzlCLElBQUksQ0FBQyxNQUFNLEVBQUU7UUFDVCxPQUFPLEtBQUssQ0FBQztLQUNoQjtJQUVELE9BQU8sTUFBTSxDQUFDLFNBQVMsQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxLQUFLLG9CQUFvQixDQUFDO0FBQzNFLENBQUM7QUFFRCxTQUFTLFlBQVksQ0FBQyxHQUFVLEVBQUUsV0FBNEI7SUFDMUQsSUFBSSxRQUFRLENBQUMsV0FBVyxDQUFDLEVBQUU7UUFDdkIsTUFBTSxJQUFJLEdBQUcsV0FBVyxDQUFDLEdBQUcsQ0FBQyxDQUFDO1FBQzlCLElBQUksSUFBSSxFQUFFO1lBQ04sT0FBTyxJQUFJLENBQUM7U0FDZjtRQUNELE9BQU8sSUFBSSxDQUFDO0tBQ2Y7U0FDSSxJQUFJLFdBQVcsQ0FBQyxXQUFXLENBQUMsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLFdBQVcsQ0FBQyxFQUFFO1FBQzdELE1BQU0sR0FBRyxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUMsV0FBVyxDQUFDLENBQUM7UUFDdkMsTUFBTSxJQUFJLEdBQUcsR0FBRyxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDLENBQUMsS0FBSyxHQUFHLENBQUMsQ0FBQztRQUN0QyxJQUFJLElBQUksRUFBRTtZQUNOLE9BQU8sSUFBSSxDQUFDO1NBQ2Y7UUFFRCxNQUFNLE9BQU8sR0FBRyxHQUFHLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7UUFDM0MsSUFBSSxPQUFPLEVBQUU7WUFDVCxNQUFNLElBQUksR0FBRyxPQUFPLENBQUMsR0FBRyxDQUFDLENBQUM7WUFDMUIsSUFBSSxJQUFJLEVBQUU7Z0JBQ04sT0FBTyxJQUFJLENBQUM7YUFDZjtZQUNELE9BQU8sSUFBSSxDQUFDO1NBQ2Y7UUFFRCxPQUFPLElBQUksQ0FBQztLQUNmO1NBQ0k7UUFDRCxlQUFlO1FBQ2YsT0FBTyxJQUFJLENBQUM7S0FDZjtBQUNMLENBQUM7QUFFRDs7Ozs7R0FLRztBQUNILFNBQVMsR0FBRyxDQUFDLEdBQVUsRUFBRSxPQUFnQjtJQUVyQyxJQUFJLENBQUMsTUFBTSxDQUFDLFNBQVMsSUFBSSxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFDLFNBQVMsQ0FBQyxFQUFFO1FBQ3ZELE9BQU8sRUFBRSxDQUFDO0tBQ2I7SUFFRCxJQUFJLENBQUMsT0FBTyxFQUFFO1FBRVYsS0FBSyxJQUFJLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxHQUFHLE1BQU0sQ0FBQyxTQUFTLENBQUMsTUFBTSxFQUFFLEtBQUssRUFBRSxFQUFFO1lBQzFELE1BQU0sSUFBSSxHQUFHLFlBQVksQ0FBQyxHQUFHLEVBQUUsTUFBTSxDQUFDLFNBQVMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDO1lBQ3hELElBQUksQ0FBQyxJQUFJLEVBQUU7Z0JBQ1AsU0FBUzthQUNaO1lBRUQsT0FBTyxJQUFJLENBQUM7U0FDZjtLQUNKO1NBQ0k7UUFDRCxLQUFLLElBQUksS0FBSyxHQUFHLE1BQU0sQ0FBQyxTQUFTLENBQUMsTUFBTSxFQUFFLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxFQUFFLEVBQUU7WUFDMUQsTUFBTSxJQUFJLEdBQUcsWUFBWSxDQUFDLEdBQUcsRUFBRSxNQUFNLENBQUMsU0FBUyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUM7WUFDeEQsSUFBSSxDQUFDLElBQUksRUFBRTtnQkFDUCxTQUFTO2FBQ1o7WUFFRCxPQUFPLElBQUksQ0FBQztTQUNmO0tBQ0o7QUFDTCxDQUFDO0FBRU8sa0JBQUcifQ==
@@ -1,11 +1,24 @@
1
1
  import { ga4Option } from "./ga4option";
2
2
  import { KeyValueParams, gtag } from "../types/gtag";
3
+ import { GA4Plugin, SendFunction } from "../types/plugins";
3
4
  declare class ga4 {
4
5
  private static instance;
6
+ private _plugins;
5
7
  private constructor();
6
8
  init(option: ga4Option): void;
7
9
  static getInstance(): ga4;
8
10
  send(eventName: string, eventParameters: KeyValueParams): boolean;
11
+ /**
12
+ * Register a plugin with the GA4 instance.
13
+ * @param PluginClass The plugin class constructor
14
+ * @param options Plugin-specific configuration options
15
+ * @returns The plugin instance (call `.remove()` to unregister)
16
+ */
17
+ use<T extends GA4Plugin>(PluginClass: new (send: SendFunction, options?: any) => T, options?: any): T;
18
+ /**
19
+ * Remove all registered plugins and clean up their listeners.
20
+ */
21
+ removeAll(): void;
9
22
  get gtag(): gtag;
10
23
  }
11
24
  export { ga4 };