onedollarstats 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Drizzle Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # OneDollarStats
2
+
3
+ [![npm version](https://img.shields.io/npm/v/onedollarstats)](https://www.npmjs.com/package/onedollarstats)
4
+ [![Website](https://img.shields.io/badge/site-onedollarstats.com-blue)](https://onedollarstats.com/home)
5
+
6
+ A lightweight, zero-dependency analytics tracker for client apps. OneDollarStats automatically collects pageviews, UTM parameters, and custom events with minimal setup.
7
+
8
+ ## Features
9
+
10
+ -Automatic pageview tracking (supports client/server side navigation and hash routing)
11
+ -Automatic UTM parameter collection
12
+ -Automatic event tracking on clicks of elements with data-s-event attributes
13
+ -Zero dependencies, easy to integrate
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm i onedollarstats
19
+ ```
20
+
21
+ ## Getting Started
22
+
23
+ ### Configure analytics
24
+
25
+ > ⚠️ Initialize analytics on every page for static sites, or at the root layout (app entrypoint) in SPA apps.
26
+ > Calling `view` or `event` before `configure` will automatically initialize the tracker with the default configuration.
27
+
28
+ ```ts
29
+ import { configure } from "onedollarstats";
30
+
31
+ // Configure analytics
32
+ configure({
33
+ collectorUrl: "https://collector.onedollarstats.com/events",
34
+ autocollect: true, // automatically tracks pageviews & clicks
35
+ hashRouting: true // track SPA hash route changes
36
+ });
37
+ ```
38
+
39
+ #### Track Pageviews Manually
40
+
41
+ By default, pageviews are tracked automatically. If you want to track them manually (for example, with autocollect: false), you can use the `view` function:
42
+
43
+ ```ts
44
+ import { view } from "onedollarstats";
45
+
46
+ // Simple pageview
47
+ view("/homepage");
48
+
49
+ // Pageview with extra properties
50
+ view("/checkout", { step: 2, plan: "pro" });
51
+ ```
52
+
53
+ #### Track Custom Events Manually
54
+
55
+ The `event` function can accept different types of arguments depending on your needs:
56
+
57
+ ```ts
58
+ import { event } from "onedollarstats";
59
+
60
+ // Simple event
61
+ event("Purchase");
62
+
63
+ // Event with a path
64
+ event("Purchase", "/product");
65
+
66
+ // Event with properties
67
+ event("Purchase", { amount: 1, color: "green" });
68
+
69
+ // Event with path + properties
70
+ event("Purchase", "/product", { amount: 1, color: "green" });
71
+ ```
72
+
73
+ ## API
74
+
75
+ #### `configure(config?: AnalyticsConfig)` initializes the tracker with your configuration.
76
+
77
+ **Config Options:**
78
+
79
+ | Option | Type | Default | Description |
80
+ | ------------------ | ---------------- | ----------------------------------------------- | ------------------------------------------ |
81
+ | `collectorUrl` | `string` | `"https://collector.onedollarstats.com/events"` | URL to send analytics events |
82
+ | `trackLocalhostAs` | `string \| null` | `null` | Replace localhost hostname for dev testing |
83
+ | `hashRouting` | `boolean` | `false` | Track hash route changes as pageviews |
84
+ | `autocollect` | `boolean` | `true` | Automatically track pageviews & clicks |
85
+ | `excludePages` | `string[]` | `[]` | Pages to ignore for automatic tracking |
86
+ | `includePages` | `string[]` | `[]` | Pages to explicitly include for tracking |
87
+
88
+ > **Notes:**
89
+ >
90
+ > - Manual calls of `view` or `event` **ignore** `excludePages`/`includePages`.
91
+ > - By default, events from `localhost` are ignored. Use the `trackLocalhostAs` option to simulate a hostname for local development.
92
+
93
+ ---
94
+
95
+ #### `view(pathOrProps?: string | Record<string, string>, props?: Record<string, string>)` sends a pageview event.
96
+
97
+ **Parameters:**
98
+
99
+ - `pathOrProps` – Optional, **string** represents the path, **object** represents custom properties.
100
+ - `props` – Optional, properties if the first argument is a path string.
101
+
102
+ ---
103
+
104
+ #### `event(eventName: string, pathOrProps?: string | Record<string, string>, props?: Record<string, string>)` sends a custom event.
105
+
106
+ **Parameters:**
107
+
108
+ - `eventName` – Name of the event.
109
+ - `pathOrProps` – Optional, **string** represents the path, **object** represents custom properties.
110
+ - `props` – Optional, properties if the second argument is a path string.
111
+
112
+ ---
113
+
114
+ ## Click Autocapture
115
+
116
+ Automatically capture clicks on elements using these HTML attributes:
117
+
118
+ - `data-s-event`– Name of the event
119
+ - `data-s-event-path` Optional, the path representing the page where the event occurred
120
+ - `data-s-event-props` – Optional, properties to send with the event
121
+
122
+ For full details, see the [Click Autocapture documentation](https://docs.onedollarstats.com/send-events).
@@ -0,0 +1,4 @@
1
+ import type { AnalyticsConfig, BaseProps } from "./types";
2
+ export declare const configure: (userConfig?: AnalyticsConfig) => void;
3
+ export declare const event: (eventName: string, pathOrProps?: string | BaseProps, props?: BaseProps) => Promise<void>;
4
+ export declare const view: (pathOrProps?: string | BaseProps, props?: BaseProps) => Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ // src/utils/environment.ts
2
+ var getEnvironment = () => ({
3
+ isLocalhost: /^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) || location.protocol === "file:",
4
+ isHeadlessBrowser: Boolean(
5
+ window.navigator.webdriver || "_phantom" in window && window._phantom || "__nightmare" in window && window.__nightmare || "Cypress" in window && window.Cypress
6
+ )
7
+ });
8
+ var isClient = () => {
9
+ try {
10
+ if (typeof window === "undefined" || typeof document === "undefined") return false;
11
+ const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
12
+ if (/node|jsdom/i.test(ua)) return false;
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ };
18
+
19
+ // src/utils/parse-utm-params.ts
20
+ var parseUtmParams = (urlSearchParams) => {
21
+ const utm = {};
22
+ ["utm_campaign", "utm_source", "utm_medium", "utm_term", "utm_content"].forEach((key) => {
23
+ const values = urlSearchParams.getAll(key);
24
+ if (values.length === 1) {
25
+ utm[key] = values[0];
26
+ } else if (values.length > 1) {
27
+ utm[key] = values;
28
+ }
29
+ });
30
+ return utm;
31
+ };
32
+
33
+ // src/utils/props-parser.ts
34
+ var parseProps = (propsString) => {
35
+ if (!propsString) return void 0;
36
+ const splittedProps = propsString.split(";");
37
+ const propsObj = {};
38
+ for (const keyValueString of splittedProps) {
39
+ const keyValuePair = keyValueString.split("=").map((el) => el.trim());
40
+ if (keyValuePair.length !== 2 || keyValuePair[0] === "" || keyValuePair[1] === "") continue;
41
+ propsObj[keyValuePair[0]] = keyValuePair[1];
42
+ }
43
+ return Object.keys(propsObj).length === 0 ? void 0 : propsObj;
44
+ };
45
+
46
+ // src/utils/should-track.ts
47
+ var matchesPattern = (path, pattern) => {
48
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
49
+ return new RegExp(`^${escaped}$`).test(path);
50
+ };
51
+ var shouldTrackPath = (path, config) => {
52
+ if (config.excludePages.some((pattern) => matchesPattern(path, pattern))) return false;
53
+ if (config.includePages.length && !config.includePages.some((pattern) => matchesPattern(path, pattern))) return false;
54
+ return true;
55
+ };
56
+
57
+ // src/index.ts
58
+ var defaultConfig = {
59
+ trackLocalhostAs: null,
60
+ collectorUrl: "https://collector.onedollarstats.com/events",
61
+ hashRouting: false,
62
+ autocollect: true,
63
+ excludePages: [],
64
+ includePages: []
65
+ };
66
+ var _AnalyticsTracker = class _AnalyticsTracker {
67
+ constructor(userConfig = {}) {
68
+ this.autocollectSetupDone = false;
69
+ this.lastPage = null;
70
+ this.config = { ...defaultConfig, ...userConfig };
71
+ if (!isClient()) return;
72
+ if (this.config.autocollect) this.setupAutocollect();
73
+ }
74
+ static getInstance(userConfig = {}) {
75
+ if (!isClient()) {
76
+ console.warn("[onedollarstats] Running in non-browser environment. Returning no-op instance.");
77
+ return new _AnalyticsTracker(userConfig);
78
+ }
79
+ if (!_AnalyticsTracker.instance) {
80
+ _AnalyticsTracker.instance = new _AnalyticsTracker(userConfig);
81
+ }
82
+ return _AnalyticsTracker.instance;
83
+ }
84
+ async sendWithBeaconOrFetch(stringifiedBody) {
85
+ if (navigator.sendBeacon?.(this.config.collectorUrl, stringifiedBody)) return;
86
+ fetch(this.config.collectorUrl, {
87
+ method: "POST",
88
+ body: stringifiedBody,
89
+ headers: { "Content-Type": "application/json" },
90
+ keepalive: true
91
+ }).catch((err) => console.error("[onedollarstats] fetch() failed:", err.message));
92
+ }
93
+ // Handles localhost replacement, referrer, UTM parameters, and debug mode.
94
+ // Uses img beacon then `navigator.sendBeacon` if available, otherwise falls back to `fetch`.
95
+ async send(data) {
96
+ const { isLocalhost, isHeadlessBrowser } = getEnvironment();
97
+ if (isLocalhost && !this.config.trackLocalhostAs || isHeadlessBrowser) return;
98
+ const urlToSend = new URL(location.href);
99
+ let isDebug = false;
100
+ if (isLocalhost && this.config.trackLocalhostAs && urlToSend.hostname !== this.config.trackLocalhostAs) {
101
+ isDebug = true;
102
+ urlToSend.hostname = this.config.trackLocalhostAs;
103
+ }
104
+ urlToSend.search = "";
105
+ if (data.path) urlToSend.pathname = data.path;
106
+ const cleanUrl = urlToSend.href.replace(/\/$/, "");
107
+ let referrer = data.referrer;
108
+ try {
109
+ if (!referrer && document.referrer && document.referrer !== "null") {
110
+ const referrerURL = new URL(document.referrer);
111
+ if (referrerURL.hostname !== urlToSend.hostname) referrer = referrerURL.href;
112
+ }
113
+ } catch {
114
+ }
115
+ const body = {
116
+ u: cleanUrl,
117
+ e: [
118
+ {
119
+ t: data.type,
120
+ h: this.config.hashRouting,
121
+ r: referrer,
122
+ p: data.props
123
+ }
124
+ ]
125
+ };
126
+ if (data.utm && Object.keys(data.utm).length > 0) body.qs = data.utm;
127
+ if (isDebug) body.debug = true;
128
+ const stringifiedBody = JSON.stringify(body);
129
+ const payloadBase64 = btoa(stringifiedBody);
130
+ const safeGetThreshold = 1500;
131
+ const tryImageBeacon = payloadBase64.length <= safeGetThreshold;
132
+ if (tryImageBeacon) {
133
+ const img = new Image(1, 1);
134
+ img.onerror = () => {
135
+ this.sendWithBeaconOrFetch(stringifiedBody).catch((err) => console.error("[onedollarstats] fallback failed:", err?.message || err));
136
+ };
137
+ img.src = `${this.config.collectorUrl}?data=${payloadBase64}`;
138
+ }
139
+ await this.sendWithBeaconOrFetch(stringifiedBody);
140
+ }
141
+ // Prevents duplicate pageviews and respects include/exclude page rules. Automatically parses UTM parameters from URL.
142
+ trackPageView({ path, props }, checkBlock = false) {
143
+ if (!isClient()) return;
144
+ const cleanPath = path || location.pathname;
145
+ if (!this.config.hashRouting && this.lastPage === cleanPath) return;
146
+ if (checkBlock && !shouldTrackPath(cleanPath, this.config)) return;
147
+ this.lastPage = cleanPath;
148
+ const utm = parseUtmParams(new URLSearchParams(location.search));
149
+ this.send({ type: "PageView", path: cleanPath, props, utm });
150
+ }
151
+ /**
152
+ * Tracks a custom event.
153
+ * Can accept path string or a props object.
154
+ *
155
+ * @param eventName Name of the event to track.
156
+ * @param pathOrProps Optional path string or props object.
157
+ * @param props Optional props object if path string is provided.
158
+ */
159
+ async event(eventName, pathOrProps, props) {
160
+ if (!isClient()) return;
161
+ const { isLocalhost, isHeadlessBrowser } = getEnvironment();
162
+ if (isLocalhost && !this.config.trackLocalhostAs || isHeadlessBrowser) return;
163
+ const args = {};
164
+ if (typeof pathOrProps === "string") {
165
+ args.path = pathOrProps;
166
+ args.props = props;
167
+ } else if (typeof pathOrProps === "object") args.props = pathOrProps;
168
+ this.send({ type: eventName, ...args });
169
+ }
170
+ /**
171
+ * Records a page view.
172
+ * Can accept path string or a props object.
173
+ *
174
+ * @param pathOrProps Optional path string or props object.
175
+ * @param props Optional props when first arg is a path string.
176
+ */
177
+ async view(pathOrProps, props) {
178
+ if (!isClient()) return;
179
+ const args = {};
180
+ if (typeof pathOrProps === "string") {
181
+ args.path = pathOrProps;
182
+ args.props = props;
183
+ } else if (typeof pathOrProps === "object") {
184
+ args.props = pathOrProps;
185
+ }
186
+ this.trackPageView(args);
187
+ }
188
+ /**
189
+ * Installs global DOM/window listeners exactly once for:
190
+ * - visibilitychange
191
+ * - history.pushState
192
+ * - popstate
193
+ * - hashchange
194
+ * - click autocapture for elements annotated with `data-s:event` & `data-s-event`
195
+ *
196
+ */
197
+ setupAutocollect() {
198
+ if (!isClient() || this.autocollectSetupDone) return;
199
+ this.autocollectSetupDone = true;
200
+ const handlePageView = () => this.trackPageView({ path: location.pathname }, true);
201
+ const onVisibility = () => {
202
+ if (document.visibilityState === "visible") handlePageView();
203
+ };
204
+ document.addEventListener("visibilitychange", onVisibility);
205
+ const origPush = history.pushState.bind(history);
206
+ history.pushState = (...args) => {
207
+ origPush(...args);
208
+ requestAnimationFrame(() => {
209
+ handlePageView();
210
+ });
211
+ };
212
+ window.addEventListener("popstate", handlePageView);
213
+ window.addEventListener("hashchange", handlePageView);
214
+ const onClick = (ev) => {
215
+ const clickEvent = ev;
216
+ if (clickEvent.type === "auxclick" && clickEvent.button !== 1) return;
217
+ const target = clickEvent.target;
218
+ if (!target) return;
219
+ const insideInteractive = !!target.closest("a, button");
220
+ let el = target;
221
+ let depth = 0;
222
+ while (el) {
223
+ const eventName = el.getAttribute("data-s:event") ?? el.getAttribute("data-s-event");
224
+ if (eventName) {
225
+ const propsAttr = el.getAttribute("data-s:event-props") ?? el.getAttribute("data-s-event-props");
226
+ const props = propsAttr ? parseProps(propsAttr) : void 0;
227
+ const path = el.getAttribute("data-s:event-path") || el.getAttribute("data-s-event-path") || void 0;
228
+ if (path && !shouldTrackPath(path, this.config) || !shouldTrackPath(location.pathname, this.config)) {
229
+ return;
230
+ }
231
+ this.event(eventName, path ?? props, props);
232
+ return;
233
+ }
234
+ el = el.parentElement;
235
+ depth++;
236
+ if (!insideInteractive && depth >= 3) break;
237
+ }
238
+ };
239
+ document.addEventListener("click", onClick);
240
+ if (document.visibilityState === "visible") handlePageView();
241
+ }
242
+ };
243
+ _AnalyticsTracker.instance = null;
244
+ var AnalyticsTracker = _AnalyticsTracker;
245
+ var configure = (userConfig = {}) => {
246
+ AnalyticsTracker.getInstance(userConfig);
247
+ };
248
+ var event = async (eventName, pathOrProps, props) => {
249
+ const instance = AnalyticsTracker.getInstance();
250
+ await instance.event(eventName, pathOrProps, props);
251
+ };
252
+ var view = async (pathOrProps, props) => {
253
+ const instance = AnalyticsTracker.getInstance();
254
+ await instance.view(pathOrProps, props);
255
+ };
256
+ export {
257
+ configure,
258
+ event,
259
+ view
260
+ };
261
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/utils/environment.ts", "../src/utils/parse-utm-params.ts", "../src/utils/props-parser.ts", "../src/utils/should-track.ts", "../src/index.ts"],
4
+ "sourcesContent": ["export const getEnvironment = (): {\n isLocalhost: boolean;\n isHeadlessBrowser: boolean;\n} => ({\n isLocalhost: /^localhost$|^127(\\.[0-9]+){0,2}\\.[0-9]+$|^\\[::1?\\]$/.test(location.hostname) || location.protocol === \"file:\",\n isHeadlessBrowser: Boolean(\n window.navigator.webdriver ||\n (\"_phantom\" in window && window._phantom) ||\n (\"__nightmare\" in window && window.__nightmare) ||\n (\"Cypress\" in window && window.Cypress)\n )\n});\nexport const isClient = (): boolean => {\n try {\n // Basic checks for window and document\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return false;\n\n // Check for navigator safely\n const ua = typeof navigator !== \"undefined\" ? navigator.userAgent : \"\";\n if (/node|jsdom/i.test(ua)) return false;\n return true;\n } catch {\n return false;\n }\n};\n", "export const parseUtmParams = (urlSearchParams: URLSearchParams) => {\n const utm: Record<string, string | string[]> = {};\n\n [\"utm_campaign\", \"utm_source\", \"utm_medium\", \"utm_term\", \"utm_content\"].forEach((key) => {\n const values = urlSearchParams.getAll(key);\n if (values.length === 1) {\n utm[key] = values[0];\n } else if (values.length > 1) {\n utm[key] = values; // store array if multiple values\n }\n });\n\n return utm;\n};\n", "export const parseProps = (propsString: string): Record<string, string> | undefined => {\n if (!propsString) return undefined;\n // \"key1=value1;key2=value2\"\n\n const splittedProps = propsString.split(\";\");\n const propsObj: Record<string, string> = {};\n\n for (const keyValueString of splittedProps) {\n const keyValuePair = keyValueString.split(\"=\").map((el) => el.trim());\n if (keyValuePair.length !== 2 || keyValuePair[0] === \"\" || keyValuePair[1] === \"\") continue;\n // @ts-ignore\n propsObj[keyValuePair[0]] = keyValuePair[1];\n }\n\n return Object.keys(propsObj).length === 0 ? undefined : propsObj;\n};\n", "import type { AnalyticsConfig } from \"../types\";\n\nconst matchesPattern = (path: string, pattern: string): boolean => {\n // Escape special regex characters except '*' which becomes '.*'\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\").replace(/\\*/g, \".*\");\n return new RegExp(`^${escaped}$`).test(path);\n};\n\nexport const shouldTrackPath = (path: string, config: Required<AnalyticsConfig>): boolean => {\n // Exclude pages first\n if (config.excludePages.some((pattern) => matchesPattern(path, pattern))) return false;\n // If includePages is defined, only allow matching paths\n if (config.includePages.length && !config.includePages.some((pattern) => matchesPattern(path, pattern))) return false;\n return true;\n};\n", "import type { AnalyticsConfig, BaseProps, BodyToSend, Event, ViewArguments } from \"./types\";\nimport { getEnvironment, isClient } from \"./utils/environment\";\nimport { parseUtmParams } from \"./utils/parse-utm-params\";\nimport { parseProps } from \"./utils/props-parser\";\nimport { shouldTrackPath } from \"./utils/should-track\";\n\nconst defaultConfig: Required<AnalyticsConfig> = {\n trackLocalhostAs: null,\n collectorUrl: \"https://collector.onedollarstats.com/events\",\n hashRouting: false,\n autocollect: true,\n excludePages: [],\n includePages: []\n};\n\nclass AnalyticsTracker {\n private static instance: AnalyticsTracker | null = null;\n\n private autocollectSetupDone = false;\n private config: Required<AnalyticsConfig>;\n private lastPage: string | null = null;\n\n public static getInstance(userConfig: AnalyticsConfig = {}): AnalyticsTracker {\n if (!isClient()) {\n console.warn(\"[onedollarstats] Running in non-browser environment. Returning no-op instance.\");\n return new AnalyticsTracker(userConfig); // Fresh no-op instance for SSR\n }\n\n if (!AnalyticsTracker.instance) {\n AnalyticsTracker.instance = new AnalyticsTracker(userConfig);\n }\n return AnalyticsTracker.instance;\n }\n\n private constructor(userConfig: AnalyticsConfig = {}) {\n this.config = { ...defaultConfig, ...userConfig };\n\n // Skip setup in non-client environments\n if (!isClient()) return;\n\n // Auto-start autocollect\n if (this.config.autocollect) this.setupAutocollect();\n }\n\n private async sendWithBeaconOrFetch(stringifiedBody: string): Promise<void> {\n // First fallback: try sendBeacon\n if (navigator.sendBeacon?.(this.config.collectorUrl, stringifiedBody)) return;\n\n // Second fallback: use fetch() with keepalive\n fetch(this.config.collectorUrl, {\n method: \"POST\",\n body: stringifiedBody,\n headers: { \"Content-Type\": \"application/json\" },\n keepalive: true\n }).catch((err: Error) => console.error(\"[onedollarstats] fetch() failed:\", err.message));\n }\n\n // Handles localhost replacement, referrer, UTM parameters, and debug mode.\n // Uses img beacon then `navigator.sendBeacon` if available, otherwise falls back to `fetch`.\n private async send(data: Event): Promise<void> {\n const { isLocalhost, isHeadlessBrowser } = getEnvironment();\n if ((isLocalhost && !this.config.trackLocalhostAs) || isHeadlessBrowser) return;\n\n const urlToSend = new URL(location.href);\n\n // Determine debug mode and handle localhost replacement\n let isDebug: boolean = false;\n if (isLocalhost && this.config.trackLocalhostAs && urlToSend.hostname !== this.config.trackLocalhostAs) {\n isDebug = true;\n urlToSend.hostname = this.config.trackLocalhostAs;\n }\n\n // Clean query string unless UTM is explicitly provided\n urlToSend.search = \"\";\n if (data.path) urlToSend.pathname = data.path;\n\n const cleanUrl = urlToSend.href.replace(/\\/$/, \"\");\n\n // Determine referrer\n let referrer: string | undefined = data.referrer;\n try {\n if (!referrer && document.referrer && document.referrer !== \"null\") {\n const referrerURL = new URL(document.referrer);\n if (referrerURL.hostname !== urlToSend.hostname) referrer = referrerURL.href;\n }\n } catch {} // ignore malformed referrer\n\n // Build request body\n const body: BodyToSend = {\n u: cleanUrl,\n e: [\n {\n t: data.type,\n h: this.config.hashRouting,\n r: referrer,\n p: data.props\n }\n ]\n };\n\n if (data.utm && Object.keys(data.utm).length > 0) body.qs = data.utm;\n if (isDebug) body.debug = true;\n\n // Prepare the event payload\n const stringifiedBody = JSON.stringify(body);\n // Encode for safe inclusion in query string using Base64\n const payloadBase64 = btoa(stringifiedBody);\n\n const safeGetThreshold = 1500; // limit for query-string-containing URLs\n const tryImageBeacon = payloadBase64.length <= safeGetThreshold;\n\n if (tryImageBeacon) {\n // Send via image beacon\n const img = new Image(1, 1);\n\n // If loading image fails (server unavailable, blocked, etc.)\n img.onerror = () => {\n this.sendWithBeaconOrFetch(stringifiedBody).catch((err) => console.error(\"[onedollarstats] fallback failed:\", err?.message || err));\n };\n\n // Primary attempt: send data via image beacon (GET request with query string)\n img.src = `${this.config.collectorUrl}?data=${payloadBase64}`;\n }\n\n await this.sendWithBeaconOrFetch(stringifiedBody);\n }\n\n // Prevents duplicate pageviews and respects include/exclude page rules. Automatically parses UTM parameters from URL.\n private trackPageView({ path, props }: ViewArguments, checkBlock: boolean = false) {\n if (!isClient()) return;\n\n const cleanPath = path || location.pathname;\n\n // Skip duplicate pageviews or excluded pages\n if (!this.config.hashRouting && this.lastPage === cleanPath) return;\n\n // Skip page if checkBlock is true and the path should be excluded\n if (checkBlock && !shouldTrackPath(cleanPath, this.config)) return;\n\n this.lastPage = cleanPath;\n\n const utm = parseUtmParams(new URLSearchParams(location.search));\n this.send({ type: \"PageView\", path: cleanPath, props, utm });\n }\n\n /**\n * Tracks a custom event.\n * Can accept path string or a props object.\n *\n * @param eventName Name of the event to track.\n * @param pathOrProps Optional path string or props object.\n * @param props Optional props object if path string is provided.\n */\n public async event(eventName: string, pathOrProps?: string | BaseProps, props?: BaseProps) {\n if (!isClient()) return;\n\n const { isLocalhost, isHeadlessBrowser } = getEnvironment();\n if ((isLocalhost && !this.config.trackLocalhostAs) || isHeadlessBrowser) return;\n\n const args: ViewArguments = {};\n if (typeof pathOrProps === \"string\") {\n args.path = pathOrProps;\n args.props = props;\n } else if (typeof pathOrProps === \"object\") args.props = pathOrProps;\n\n this.send({ type: eventName, ...args });\n }\n\n /**\n * Records a page view.\n * Can accept path string or a props object.\n *\n * @param pathOrProps Optional path string or props object.\n * @param props Optional props when first arg is a path string.\n */\n public async view(pathOrProps?: string | BaseProps, props?: BaseProps) {\n if (!isClient()) return;\n\n const args: ViewArguments = {};\n\n if (typeof pathOrProps === \"string\") {\n args.path = pathOrProps;\n args.props = props;\n } else if (typeof pathOrProps === \"object\") {\n args.props = pathOrProps;\n }\n\n this.trackPageView(args);\n }\n\n /**\n * Installs global DOM/window listeners exactly once for:\n * - visibilitychange\n * - history.pushState\n * - popstate\n * - hashchange\n * - click autocapture for elements annotated with `data-s:event` & `data-s-event`\n *\n */\n private setupAutocollect() {\n if (!isClient() || this.autocollectSetupDone) return;\n this.autocollectSetupDone = true;\n\n const handlePageView = () => this.trackPageView({ path: location.pathname }, true);\n\n // visibilitychange\n const onVisibility = () => {\n if (document.visibilityState === \"visible\") handlePageView();\n };\n document.addEventListener(\"visibilitychange\", onVisibility);\n\n // pushState\n const origPush = history.pushState.bind(history);\n history.pushState = (...args) => {\n origPush(...args);\n requestAnimationFrame(() => {\n handlePageView();\n });\n };\n\n // popstate\n window.addEventListener(\"popstate\", handlePageView);\n\n // hashchange\n window.addEventListener(\"hashchange\", handlePageView);\n\n // click autocapture\n const onClick: EventListener = (ev: Event) => {\n const clickEvent = ev as MouseEvent;\n if (clickEvent.type === \"auxclick\" && clickEvent.button !== 1) return;\n\n const target = clickEvent.target as Element | null;\n if (!target) return;\n\n // Check if inside <a> or <button>\n const insideInteractive = !!target.closest(\"a, button\");\n\n let el: Element | null = target;\n let depth = 0;\n\n while (el) {\n const eventName = el.getAttribute(\"data-s:event\") ?? el.getAttribute(\"data-s-event\");\n if (eventName) {\n const propsAttr = el.getAttribute(\"data-s:event-props\") ?? el.getAttribute(\"data-s-event-props\");\n const props = propsAttr ? parseProps(propsAttr) : undefined;\n const path = el.getAttribute(\"data-s:event-path\") || el.getAttribute(\"data-s-event-path\") || undefined;\n\n if ((path && !shouldTrackPath(path, this.config)) || !shouldTrackPath(location.pathname, this.config)) {\n return;\n }\n\n this.event(eventName, path ?? props, props);\n return;\n }\n\n el = el.parentElement;\n depth++;\n\n // If not in <a>/<button>, stop after 3 levels\n if (!insideInteractive && depth >= 3) break;\n }\n };\n\n document.addEventListener(\"click\", onClick);\n\n // Fire initial pageview if already visible\n if (document.visibilityState === \"visible\") handlePageView();\n }\n}\n\nexport const configure = (userConfig: AnalyticsConfig = {}) => {\n AnalyticsTracker.getInstance(userConfig);\n};\n\nexport const event = async (eventName: string, pathOrProps?: string | BaseProps, props?: BaseProps) => {\n const instance = AnalyticsTracker.getInstance();\n await instance.event(eventName, pathOrProps, props);\n};\n\nexport const view = async (pathOrProps?: string | BaseProps, props?: BaseProps) => {\n const instance = AnalyticsTracker.getInstance();\n await instance.view(pathOrProps, props);\n};\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,34 @@
1
+ type UtmParams = Record<string, string | string[]>;
2
+ export type BaseProps = Record<string, string>;
3
+ type MinimizedEvent = {
4
+ t: string;
5
+ h?: boolean;
6
+ r?: string;
7
+ p?: BaseProps;
8
+ };
9
+ export type Event = {
10
+ type: string;
11
+ path?: string;
12
+ props?: BaseProps;
13
+ utm?: UtmParams;
14
+ referrer?: string;
15
+ };
16
+ export type BodyToSend = {
17
+ u: string;
18
+ e: [MinimizedEvent];
19
+ qs?: UtmParams;
20
+ debug?: boolean;
21
+ };
22
+ export type ViewArguments = {
23
+ path?: string;
24
+ props?: BaseProps;
25
+ };
26
+ export type AnalyticsConfig = {
27
+ collectorUrl?: string;
28
+ trackLocalhostAs?: string | null;
29
+ hashRouting?: boolean;
30
+ autocollect?: boolean;
31
+ excludePages?: string[];
32
+ includePages?: string[];
33
+ };
34
+ export {};
@@ -0,0 +1,5 @@
1
+ export declare const getEnvironment: () => {
2
+ isLocalhost: boolean;
3
+ isHeadlessBrowser: boolean;
4
+ };
5
+ export declare const isClient: () => boolean;
@@ -0,0 +1 @@
1
+ export declare const parseUtmParams: (urlSearchParams: URLSearchParams) => Record<string, string | string[]>;
@@ -0,0 +1 @@
1
+ export declare const parseProps: (propsString: string) => Record<string, string> | undefined;
@@ -0,0 +1,2 @@
1
+ import type { AnalyticsConfig } from "../types";
2
+ export declare const shouldTrackPath: (path: string, config: Required<AnalyticsConfig>) => boolean;
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "onedollarstats",
3
+ "version": "0.0.1",
4
+ "description": "A lightweight, zero-dependency analytics tracker for frontend apps",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "tsc --emitDeclarationOnly && esbuild src/index.ts --bundle --outfile=dist/index.js --platform=browser --format=esm --target=es2020 --sourcemap"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "keywords": [
15
+ "analytics",
16
+ "tracking",
17
+ "lightweight",
18
+ "zero-dependency",
19
+ "javascript",
20
+ "typescript",
21
+ "events",
22
+ "onedollarstats"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "packageManager": "pnpm@10.16.1",
27
+ "devDependencies": {
28
+ "esbuild": "^0.25.10",
29
+ "typescript": "^5.9.2"
30
+ }
31
+ }