@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
package/src/ga4/ga4.ts CHANGED
@@ -1,41 +1,70 @@
1
- import { ga4Option } from "./ga4option";
2
- import { DataLayerObject } from "../types/dataLayer";
3
- import { KeyValueParams, gtag } from "../types/gtag";
4
- import {} from '../types/global';
5
-
6
- class ga4 {
7
-
8
- private static instance: ga4;
9
-
10
- private constructor() {
11
- }
12
-
13
- public init(option:ga4Option){
14
- window.dataLayer = window.dataLayer || Array<DataLayerObject>;
15
- window.gtag = window.gtag || function() {
16
- window.dataLayer.push(arguments);
17
- }
18
- window.gtag('js', new Date());
19
- window.gtag('config', option.targetId);
20
- }
21
-
22
- public static getInstance():ga4 {
23
- if (!ga4.instance) {
24
- ga4.instance = new ga4();
25
- }
26
- return ga4.instance;
27
- }
28
-
29
- public send(eventName:string, eventParameters: KeyValueParams ): boolean {
30
-
31
- window.gtag('event', eventName, eventParameters);
32
-
33
- return true;
34
- }
35
-
36
- get gtag() : gtag {
37
- return window.gtag;
38
- }
39
- }
40
-
1
+ import { ga4Option } from "./ga4option";
2
+ import { DataLayerObject } from "../types/dataLayer";
3
+ import { KeyValueParams, gtag } from "../types/gtag";
4
+ import { GA4Plugin, SendFunction } from "../types/plugins";
5
+ import {} from '../types/global';
6
+
7
+ class ga4 {
8
+
9
+ private static instance: ga4;
10
+ private _plugins: GA4Plugin[] = [];
11
+
12
+ private constructor() {
13
+ }
14
+
15
+ public init(option:ga4Option){
16
+ window.dataLayer = window.dataLayer || Array<DataLayerObject>;
17
+ window.gtag = window.gtag || function() {
18
+ window.dataLayer.push(arguments);
19
+ }
20
+ window.gtag('js', new Date());
21
+ window.gtag('config', option.targetId);
22
+ }
23
+
24
+ public static getInstance():ga4 {
25
+ if (!ga4.instance) {
26
+ ga4.instance = new ga4();
27
+ }
28
+ return ga4.instance;
29
+ }
30
+
31
+ public send(eventName:string, eventParameters: KeyValueParams ): boolean {
32
+
33
+ window.gtag('event', eventName, eventParameters);
34
+
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * Register a plugin with the GA4 instance.
40
+ * @param PluginClass The plugin class constructor
41
+ * @param options Plugin-specific configuration options
42
+ * @returns The plugin instance (call `.remove()` to unregister)
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ public use<T extends GA4Plugin>(
46
+ PluginClass: new (send: SendFunction, options?: any) => T,
47
+ options?: any
48
+ ): T {
49
+ const send: SendFunction = (eventName: string, params: Record<string, unknown>) => {
50
+ window.gtag('event', eventName, params as KeyValueParams);
51
+ };
52
+ const plugin = new PluginClass(send, options);
53
+ this._plugins.push(plugin);
54
+ return plugin;
55
+ }
56
+
57
+ /**
58
+ * Remove all registered plugins and clean up their listeners.
59
+ */
60
+ public removeAll(): void {
61
+ this._plugins.forEach(p => p.remove());
62
+ this._plugins = [];
63
+ }
64
+
65
+ get gtag() : gtag {
66
+ return window.gtag;
67
+ }
68
+ }
69
+
41
70
  export {ga4};
@@ -1,5 +1,5 @@
1
- interface ga4Option {
2
- targetId:string;
3
- }
4
-
1
+ interface ga4Option {
2
+ targetId:string;
3
+ }
4
+
5
5
  export {ga4Option};
package/src/ga4/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import {ga4} from './ga4';
2
-
3
- const ga = ga4.getInstance();
4
-
1
+ import {ga4} from './ga4';
2
+
3
+ const ga = ga4.getInstance();
4
+
5
5
  export {ga};
@@ -0,0 +1,28 @@
1
+ export interface DebouncedFunction<T extends (...args: never[]) => void> {
2
+ (...args: Parameters<T>): void;
3
+ cancel(): void;
4
+ }
5
+
6
+ export function debounce<T extends (...args: never[]) => void>(
7
+ fn: T,
8
+ delay: number
9
+ ): DebouncedFunction<T> {
10
+ let timer: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ const debounced = function (this: unknown, ...args: Parameters<T>) {
13
+ if (timer !== null) clearTimeout(timer);
14
+ timer = setTimeout(() => {
15
+ timer = null;
16
+ fn.apply(this, args);
17
+ }, delay);
18
+ } as DebouncedFunction<T>;
19
+
20
+ debounced.cancel = () => {
21
+ if (timer !== null) {
22
+ clearTimeout(timer);
23
+ timer = null;
24
+ }
25
+ };
26
+
27
+ return debounced;
28
+ }
@@ -0,0 +1,51 @@
1
+ export interface DelegateHandle {
2
+ destroy(): void;
3
+ }
4
+
5
+ export interface DelegateOptions {
6
+ composed?: boolean;
7
+ useCapture?: boolean;
8
+ }
9
+
10
+ export function delegate(
11
+ target: EventTarget,
12
+ eventType: string,
13
+ selector: string,
14
+ handler: (event: Event, element: Element) => void,
15
+ options?: DelegateOptions
16
+ ): DelegateHandle {
17
+ const useCapture = options?.useCapture ?? false;
18
+
19
+ const listener = (event: Event) => {
20
+ let element: Element | null = event.target as Element | null;
21
+
22
+ // Handle composed events (shadow DOM)
23
+ if (options?.composed && typeof event.composedPath === 'function') {
24
+ const path = event.composedPath();
25
+ for (const node of path) {
26
+ if (node === target) break;
27
+ if (node instanceof Element && node.matches(selector)) {
28
+ handler(event, node);
29
+ return;
30
+ }
31
+ }
32
+ return;
33
+ }
34
+
35
+ while (element && element !== target) {
36
+ if (element.matches(selector)) {
37
+ handler(event, element);
38
+ return;
39
+ }
40
+ element = element.parentElement;
41
+ }
42
+ };
43
+
44
+ target.addEventListener(eventType, listener, useCapture);
45
+
46
+ return {
47
+ destroy() {
48
+ target.removeEventListener(eventType, listener, useCapture);
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,7 @@
1
+ export function domReady(callback: () => void): void {
2
+ if (document.readyState === 'loading') {
3
+ document.addEventListener('DOMContentLoaded', callback, { once: true });
4
+ } else {
5
+ callback();
6
+ }
7
+ }
@@ -0,0 +1,37 @@
1
+ export interface ParsedUrl {
2
+ href: string;
3
+ protocol: string;
4
+ hostname: string;
5
+ port: string;
6
+ pathname: string;
7
+ search: string;
8
+ hash: string;
9
+ origin: string;
10
+ }
11
+
12
+ export function parseUrl(url: string): ParsedUrl {
13
+ try {
14
+ const parsed = new URL(url, location.href);
15
+ return {
16
+ href: parsed.href,
17
+ protocol: parsed.protocol,
18
+ hostname: parsed.hostname,
19
+ port: parsed.port,
20
+ pathname: parsed.pathname,
21
+ search: parsed.search,
22
+ hash: parsed.hash,
23
+ origin: parsed.origin,
24
+ };
25
+ } catch {
26
+ return {
27
+ href: url,
28
+ protocol: '',
29
+ hostname: '',
30
+ port: '',
31
+ pathname: url,
32
+ search: '',
33
+ hash: '',
34
+ origin: '',
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,39 @@
1
+ const SESSION_KEY_PREFIX = 'ga4_session_';
2
+
3
+ export function isSessionExpired(key: string, timeoutMinutes: number): boolean {
4
+ try {
5
+ const stored = sessionStorage.getItem(SESSION_KEY_PREFIX + key);
6
+ if (!stored) return true;
7
+ const timestamp = parseInt(stored, 10);
8
+ if (isNaN(timestamp)) return true;
9
+ return (Date.now() - timestamp) > timeoutMinutes * 60 * 1000;
10
+ } catch {
11
+ return true;
12
+ }
13
+ }
14
+
15
+ export function updateSessionTimestamp(key: string): void {
16
+ try {
17
+ sessionStorage.setItem(SESSION_KEY_PREFIX + key, String(Date.now()));
18
+ } catch {
19
+ // sessionStorage may be unavailable or full
20
+ }
21
+ }
22
+
23
+ export function getSessionValue<T>(key: string): T | null {
24
+ try {
25
+ const stored = sessionStorage.getItem(SESSION_KEY_PREFIX + key);
26
+ if (stored === null) return null;
27
+ return JSON.parse(stored) as T;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function setSessionValue(key: string, value: unknown): void {
34
+ try {
35
+ sessionStorage.setItem(SESSION_KEY_PREFIX + key, JSON.stringify(value));
36
+ } catch {
37
+ // sessionStorage may be unavailable or full
38
+ }
39
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,34 @@
1
- import {ga} from './ga4/index';
2
- import {get} from './dataLayer';
3
-
4
- const dataLayerHelper = { get };
5
- const ga4 = ga;
6
-
7
- export {ga4, dataLayerHelper};
1
+ import {ga} from './ga4/index';
2
+ import {get} from './dataLayer';
3
+
4
+ const dataLayerHelper = { get };
5
+ const ga4 = ga;
6
+
7
+ export {ga4, dataLayerHelper};
8
+
9
+ // Plugin exports
10
+ export { EventTracker } from './plugins/event-tracker';
11
+ export { OutboundLinkTracker } from './plugins/outbound-link-tracker';
12
+ export { OutboundFormTracker } from './plugins/outbound-form-tracker';
13
+ export { PageVisibilityTracker } from './plugins/page-visibility-tracker';
14
+ export { UrlChangeTracker } from './plugins/url-change-tracker';
15
+ export { ImpressionTracker } from './plugins/impression-tracker';
16
+ export { CleanUrlTracker } from './plugins/clean-url-tracker';
17
+ export { MediaQueryTracker } from './plugins/media-query-tracker';
18
+
19
+ // Type exports
20
+ export type {
21
+ GA4Plugin,
22
+ SendFunction,
23
+ EventTrackerOptions,
24
+ OutboundLinkTrackerOptions,
25
+ OutboundFormTrackerOptions,
26
+ PageVisibilityTrackerOptions,
27
+ UrlChangeTrackerOptions,
28
+ ImpressionTrackerOptions,
29
+ ImpressionElementConfig,
30
+ CleanUrlTrackerOptions,
31
+ MediaQueryTrackerOptions,
32
+ MediaQueryDefinition,
33
+ MediaQueryDefinitionItem,
34
+ } from './types/plugins';
@@ -0,0 +1,112 @@
1
+ import { GA4Plugin, SendFunction, CleanUrlTrackerOptions } from '../types/plugins';
2
+
3
+ /**
4
+ * Normalizes URLs before they are sent with `page_view` events.
5
+ *
6
+ * Intercepts `gtag()` calls for `config` and `page_view` events
7
+ * and cleans the `page_location` and `page_path` parameters
8
+ * (strip query params, normalize trailing slashes, apply custom filters).
9
+ */
10
+ export class CleanUrlTracker implements GA4Plugin {
11
+ private opts: CleanUrlTrackerOptions;
12
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
13
+ private originalGtag: Function | null = null;
14
+
15
+ constructor(_send: SendFunction, options?: CleanUrlTrackerOptions) {
16
+ this.opts = {
17
+ stripQuery: options?.stripQuery ?? false,
18
+ queryParamsAllowlist: options?.queryParamsAllowlist,
19
+ queryParamsDenylist: options?.queryParamsDenylist,
20
+ trailingSlash: options?.trailingSlash,
21
+ urlFilter: options?.urlFilter,
22
+ };
23
+
24
+ if (typeof window !== 'undefined' && window.gtag) {
25
+ this.originalGtag = window.gtag;
26
+ const self = this;
27
+
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ (window as any).gtag = function () {
30
+ // eslint-disable-next-line prefer-rest-params
31
+ const args = Array.prototype.slice.call(arguments);
32
+
33
+ if (args.length >= 3 && typeof args[2] === 'object' && args[2] !== null) {
34
+ const isPageView = args[0] === 'event' && args[1] === 'page_view';
35
+ const isConfig = args[0] === 'config';
36
+
37
+ if (isPageView || isConfig) {
38
+ args[2] = self.cleanParams({ ...args[2] });
39
+ }
40
+ }
41
+
42
+ return self.originalGtag!.apply(window, args);
43
+ };
44
+ }
45
+ }
46
+
47
+ private cleanParams(params: Record<string, unknown>): Record<string, unknown> {
48
+ if (typeof params.page_location === 'string') {
49
+ params.page_location = this.cleanUrl(params.page_location);
50
+ }
51
+ if (typeof params.page_path === 'string') {
52
+ params.page_path = this.cleanPath(params.page_path);
53
+ }
54
+ return params;
55
+ }
56
+
57
+ cleanUrl(url: string): string {
58
+ try {
59
+ const u = new URL(url);
60
+ u.pathname = this.cleanPath(u.pathname);
61
+
62
+ if (this.opts.stripQuery) {
63
+ if (this.opts.queryParamsAllowlist && this.opts.queryParamsAllowlist.length > 0) {
64
+ const allowed = new URLSearchParams();
65
+ this.opts.queryParamsAllowlist.forEach((param) => {
66
+ if (u.searchParams.has(param)) {
67
+ allowed.set(param, u.searchParams.get(param)!);
68
+ }
69
+ });
70
+ u.search = allowed.toString() ? '?' + allowed.toString() : '';
71
+ } else {
72
+ u.search = '';
73
+ }
74
+ } else if (this.opts.queryParamsDenylist && this.opts.queryParamsDenylist.length > 0) {
75
+ this.opts.queryParamsDenylist.forEach((param) => {
76
+ u.searchParams.delete(param);
77
+ });
78
+ u.search = u.searchParams.toString() ? '?' + u.searchParams.toString() : '';
79
+ }
80
+
81
+ let result = u.toString();
82
+ if (this.opts.urlFilter) {
83
+ result = this.opts.urlFilter(result);
84
+ }
85
+ return result;
86
+ } catch {
87
+ return url;
88
+ }
89
+ }
90
+
91
+ cleanPath(path: string): string {
92
+ let result = path;
93
+
94
+ if (this.opts.trailingSlash === 'remove') {
95
+ result = result.length > 1 ? result.replace(/\/+$/, '') : result;
96
+ } else if (this.opts.trailingSlash === 'add') {
97
+ if (!result.endsWith('/') && !result.split('/').pop()?.includes('.')) {
98
+ result += '/';
99
+ }
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ remove(): void {
106
+ if (this.originalGtag) {
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ (window as any).gtag = this.originalGtag;
109
+ this.originalGtag = null;
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,90 @@
1
+ import { GA4Plugin, SendFunction, EventTrackerOptions } from '../types/plugins';
2
+ import { delegate, DelegateHandle } from '../helpers/delegate';
3
+
4
+ function kebabToSnake(str: string): string {
5
+ return str.replace(/-/g, '_');
6
+ }
7
+
8
+ function getAttributeParams(element: Element, prefix: string): Record<string, unknown> {
9
+ const params: Record<string, unknown> = {};
10
+ const reservedSuffixes = ['on', 'event-name'];
11
+
12
+ for (let i = 0; i < element.attributes.length; i++) {
13
+ const attr = element.attributes[i];
14
+ if (!attr.name.startsWith(prefix)) continue;
15
+
16
+ const suffix = attr.name.slice(prefix.length);
17
+ if (reservedSuffixes.includes(suffix)) continue;
18
+
19
+ params[kebabToSnake(suffix)] = attr.value;
20
+ }
21
+
22
+ return params;
23
+ }
24
+
25
+ /**
26
+ * Declarative event tracking via HTML `data-ga4-*` attributes.
27
+ *
28
+ * Listens for DOM events on elements with `data-ga4-on` attributes
29
+ * and sends GA4 events based on attribute values.
30
+ *
31
+ * @example
32
+ * ```html
33
+ * <button
34
+ * data-ga4-on="click"
35
+ * data-ga4-event-name="video_play"
36
+ * data-ga4-video-title="My Video">
37
+ * Play
38
+ * </button>
39
+ * ```
40
+ */
41
+ export class EventTracker implements GA4Plugin {
42
+ private delegates: DelegateHandle[] = [];
43
+ private send: SendFunction;
44
+ private events: string[];
45
+ private attributePrefix: string;
46
+ private hitFilter?: EventTrackerOptions['hitFilter'];
47
+
48
+ constructor(send: SendFunction, options?: EventTrackerOptions) {
49
+ this.send = send;
50
+ this.events = options?.events ?? ['click'];
51
+ this.attributePrefix = options?.attributePrefix ?? 'data-ga4-';
52
+ this.hitFilter = options?.hitFilter;
53
+
54
+ const selector = `[${this.attributePrefix}on]`;
55
+
56
+ this.events.forEach((eventType) => {
57
+ const handle = delegate(
58
+ document,
59
+ eventType,
60
+ selector,
61
+ (event, element) => this.handleEvent(event, element),
62
+ { composed: true, useCapture: true }
63
+ );
64
+ this.delegates.push(handle);
65
+ });
66
+ }
67
+
68
+ private handleEvent(event: Event, element: Element): void {
69
+ const prefix = this.attributePrefix;
70
+ const onAttr = element.getAttribute(`${prefix}on`);
71
+
72
+ if (onAttr !== event.type) return;
73
+
74
+ const eventName = element.getAttribute(`${prefix}event-name`) || event.type;
75
+ let params = getAttributeParams(element, prefix);
76
+
77
+ if (this.hitFilter) {
78
+ const filtered = this.hitFilter(params, element, event);
79
+ if (filtered === null) return;
80
+ params = filtered;
81
+ }
82
+
83
+ this.send(eventName, params);
84
+ }
85
+
86
+ remove(): void {
87
+ this.delegates.forEach((d) => d.destroy());
88
+ this.delegates = [];
89
+ }
90
+ }