getmoxel 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxel
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/PROTOCOL.md ADDED
@@ -0,0 +1,196 @@
1
+ # Moxel wire protocol, version 1
2
+
3
+ The one contract shared by the script tag, the npm package and the Convex
4
+ ingest route. Change it here first, then in `src/core.js` and
5
+ `convex/ingest.js`.
6
+
7
+ ## Privacy rules the tracker enforces
8
+
9
+ - Never read or send what anyone types. A field is "filled" or "not filled".
10
+ - Send `location.pathname` only. Never the query string or the hash.
11
+ - Inside a path, an email, a run of 6 or more digits, a uuid, or a token (16
12
+ or more letters and digits with no dash) is replaced with `*`. So
13
+ `/orders/10293847` is sent as `/orders/*`. The server does this again.
14
+ - A page opened from disk (`file://`) sends only its file name, never the
15
+ folder path, which would carry the computer's user name.
16
+ - No cookies, no `localStorage`, no `sessionStorage`. The server tells
17
+ visitors apart, the browser holds no id.
18
+ - Click labels are cut to 60 characters. Emails and runs of 6 or more digits
19
+ inside a label are replaced with `*`.
20
+ - Anything inside an element with `data-moxel-ignore` is left alone.
21
+ - Password fields, hidden fields and fields with `autocomplete="cc-*"` are
22
+ never named and never timed.
23
+
24
+ ## Request
25
+
26
+ ```
27
+ POST {endpoint}/e
28
+ Content-Type: text/plain
29
+ ```
30
+
31
+ The body is JSON sent as plain text. Plain text keeps the request "simple",
32
+ so browsers skip the CORS preflight. Sent with `navigator.sendBeacon` when
33
+ the page is hiding, otherwise `fetch` with `keepalive: true`,
34
+ `credentials: "omit"`.
35
+
36
+ Responses: `202` accepted (also for dropped bots), `400` malformed, `403`
37
+ wrong origin, `404` unknown site key, `429` slow down or daily limit reached,
38
+ `503` the write could not finish. The tracker never retries a 4xx. It retries
39
+ a network failure or a 5xx once, after 2 seconds.
40
+
41
+ The answer names the caller's origin in `Access-Control-Allow-Origin` and
42
+ allows credentials, because `sendBeacon` always sends in "include" mode and
43
+ a browser logs an error otherwise. No cookie is ever set.
44
+
45
+ ## Envelope
46
+
47
+ ```json
48
+ {
49
+ "v": 1,
50
+ "k": "mx_pub_AbC123",
51
+ "d": "vibey",
52
+ "now": 1760000000000,
53
+ "w": 1440,
54
+ "tz": "Asia/Karachi",
55
+ "ev": [ ... ]
56
+ }
57
+ ```
58
+
59
+ | Field | Type | Meaning |
60
+ |---|---|---|
61
+ | `v` | 1 | Protocol version. |
62
+ | `k` | string | Site public key. |
63
+ | `d` | `chill` \| `vibey` \| `feral` | Depth the tracker is running at. |
64
+ | `now` | integer ms | Browser clock when the batch was sent. The server uses `now - ts` to place each event, so a wrong clock does not matter. |
65
+ | `w` | integer | Viewport width in CSS pixels. |
66
+ | `tz` | string, optional | IANA time zone from `Intl`. |
67
+ | `ev` | array, 1 to 50 | Events, oldest first. |
68
+
69
+ A batch is at most 50 events and 32 KB. The tracker flushes every 5 seconds,
70
+ when 20 events are waiting, and when the page hides.
71
+
72
+ ## Events
73
+
74
+ Every event has `t` (code), `ts` (integer ms, browser clock when it happened)
75
+ and `p` (pathname, at most 200 characters).
76
+
77
+ | `t` | What happened | Extra fields | Depth |
78
+ |---|---|---|---|
79
+ | `pv` | Page view | `r` referrer host if it is another site, `us` `um` `uc` UTM source, medium, campaign. All optional. | all |
80
+ | `lv` | Page hidden or left | `ms` time the page was visible since the last `lv`. `sc` deepest scroll so far, 0 to 100 (vibey and feral only). | all |
81
+ | `fs` | Someone started a form (first focus in it) | `f` form name, `nf` count of fields | all |
82
+ | `fx` | Form submitted | `f` | all |
83
+ | `fm` | A form is on the page. Sent once per form and page load, before anyone touches it | `f`, `nf`, `fds` names of its first 8 fields, `el` optional label of its submit button | all |
84
+ | `ev` | Custom event from `track()` | `n` name, `val` optional number | all |
85
+ | `sd` | Time a named section was on screen since the last `sd` | `s` section name, `ms` | vibey, feral |
86
+ | `cl` | Click on a link or button | `el` label, `tg` `a` \| `button` \| `other`, `h` optional target (pathname for same site, host for another site) | vibey, feral |
87
+ | `fa` | Page hidden or left with a started form unsent | `f`, `fd` last field touched, `i` its position from 0 | vibey, feral |
88
+ | `ff` | Field focused | `f`, `fd`, `i` | feral |
89
+ | `fb` | Field blurred | `f`, `fd`, `i`, `ms` time in field, `fl` 1 if it holds anything, else 0 | feral |
90
+ | `fe` | Field failed validation | `f`, `fd`, `i`, `k` kind | feral |
91
+ | `rg` | Rage click: 3 or more clicks on one spot inside 1 second | `el`, `n` clicks | feral |
92
+ | `sp` | Fastest scroll on the page, sent once with `lv` | `v` pixels per second | feral |
93
+
94
+ String limits: `f`, `fd`, `s`, `n`, `us`, `um`, `uc` and each name in `fds`
95
+ 64 characters. `el` 60.
96
+ `r`, `h` 200. `k` on `fe` is one of `required`, `type`, `pattern`, `short`,
97
+ `long`, `range`, `custom`.
98
+
99
+ ### Events sent in pieces
100
+
101
+ A phone often never tells a page it is closing, so the tracker reports when
102
+ the page is hidden, not only when it unloads. One page view can therefore
103
+ send several `lv`, `sd` and `sp` events. The server adds the `ms` values up
104
+ and counts the page view as left once. `sc` is the deepest scroll so far, so
105
+ the server keeps the largest.
106
+
107
+ `fa` works the same way. Hiding the tab with a form half filled sends one.
108
+ If the person comes back and reaches another field, the next hide sends a
109
+ second `fa` naming that field, and the server moves the walk-away to it. If
110
+ they send the form, `fx` takes the walk-away back. Starting the form again
111
+ on a fresh page load (`fs`) makes any earlier walk-away final.
112
+
113
+ ### Forms on the page
114
+
115
+ `fm` describes the page, not a visitor. The tracker reads the forms once the
116
+ page is there and again 1.5 seconds later, for forms drawn late. A page
117
+ announces at most 20 forms, and skips a form with no field it may name. The
118
+ server keeps one row per page and form and stores no event for it, so `fm`
119
+ is never in the live stream or in a total.
120
+
121
+ The owner can give any listed form a name in the dashboard. That name is
122
+ shown in place of `f` from then on. `f` itself never changes, so counts made
123
+ before and after the naming stay together.
124
+
125
+ ### Names
126
+
127
+ - Form name `f`: `data-moxel-form`, else the form's `name`, else its `id`,
128
+ else its `aria-label`, else `form 1`, `form 2` by position on the page.
129
+ - Field name `fd`: `data-moxel-field`, else `name`, else `id`, else
130
+ `aria-label`, else the text of its `<label>`, else `placeholder`, else
131
+ `field 1`, `field 2` by position in the form.
132
+ - Click label `el`: `data-moxel`, else `aria-label`, else trimmed visible
133
+ text, else `title`, else `alt` of an image inside, else the tag name.
134
+ - Section name `s`: an element with `data-moxel-section="hero"`. With no
135
+ markup, the first `<header>`, `[class*=hero]` or first `<section>` in
136
+ `<main>` is tracked as `hero`.
137
+
138
+ ### Single-page apps
139
+
140
+ `history.pushState`, `history.replaceState` and `popstate` each end the
141
+ current page (`lv`) and start a new one (`pv`) when the pathname changes.
142
+
143
+ ## Loading
144
+
145
+ Script tag:
146
+
147
+ ```html
148
+ <script defer src="https://HOST/m.js"
149
+ data-site="mx_pub_AbC123"
150
+ data-depth="vibey"></script>
151
+ ```
152
+
153
+ Events go back to the origin the script was loaded from, so a local app, a
154
+ preview and the live one each talk to themselves. A copy loaded from a public
155
+ file host (unpkg, jsDelivr) uses the address built in instead.
156
+
157
+ Optional attributes: `data-endpoint` (send events somewhere else), `data-respect-dnt` (do nothing when Do Not Track or
158
+ Global Privacy Control is on), `data-debug` (log each batch to the console).
159
+
160
+ The tag exposes `window.moxel.track(name, value?)`. `name` may hold letters,
161
+ digits, spaces, dashes and underscores. `value` is an optional number.
162
+
163
+ Package:
164
+
165
+ ```js
166
+ import { init, track } from "getmoxel";
167
+ init({ siteKey: "mx_pub_AbC123", depth: "vibey" });
168
+ ```
169
+
170
+ ```jsx
171
+ import { Moxel } from "getmoxel/react";
172
+ <Moxel siteKey="mx_pub_AbC123" depth="vibey" />
173
+ ```
174
+
175
+ `init` options: `siteKey`, `depth`, `endpoint`, `respectDnt`, `debug`.
176
+ Calling `init` twice is harmless. On the server `init` does nothing.
177
+
178
+ ## Server-side and read API
179
+
180
+ Authenticated with a secret key: `Authorization: Bearer mx_sec_...`.
181
+
182
+ | Route | Does |
183
+ |---|---|
184
+ | `POST /v1/track` | Body `{ "name": "order_placed", "value": 49, "path": "/checkout" }`. Counts as a custom event. |
185
+ | `GET /v1/summary?days=7` | Totals with a plain sentence for each number. |
186
+ | `GET /v1/findings?days=7` | The ranked fix list. |
187
+ | `GET /v1/events?limit=50` | The newest raw events. |
188
+
189
+ `getmoxel/node` wraps these:
190
+
191
+ ```js
192
+ import { Moxel } from "getmoxel/node";
193
+ const moxel = new Moxel({ secretKey: process.env.MOXEL_SECRET_KEY });
194
+ await moxel.track("order_placed", { value: 49, path: "/checkout" });
195
+ const summary = await moxel.summary({ days: 7 });
196
+ ```
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # getmoxel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Privacy-first web analytics. No cookies, no stored ids, nothing typed into a
4
+ form is ever read. It watches where people give up on a site: the page they
5
+ leave, the button they hammer, the form field they stop at.
6
+
7
+ Moxel is in early access. You need a site key (it starts with `mx_pub_`),
8
+ which comes from your Moxel dashboard.
9
+
10
+ The wire format is in [PROTOCOL.md](./PROTOCOL.md).
11
+
12
+ ## Install with a script tag
13
+
14
+ ```html
15
+ <script defer src="https://www.getmoxel.com/m.js"
16
+ data-site="mx_pub_AbC123"
17
+ data-depth="vibey"></script>
18
+ ```
19
+
20
+ Your dashboard shows this line with your own site key filled in.
21
+
22
+ Optional attributes:
23
+
24
+ - `data-endpoint`: where events are sent. Defaults to the origin the script was loaded from.
25
+ - `data-respect-dnt`: do nothing when Do Not Track or Global Privacy Control is on.
26
+ - `data-debug`: log each batch to the browser console.
27
+ - `data-allow-automation`: also run inside automated browsers. Off by default, so bots stay out.
28
+
29
+ Count your own events with `window.moxel.track("signup", 49)`. The second
30
+ argument is optional and must be a number.
31
+
32
+ ## Install with the package
33
+
34
+ ```sh
35
+ npm install getmoxel
36
+ ```
37
+
38
+ ```js
39
+ import { init, track } from "getmoxel";
40
+
41
+ init({ siteKey: "mx_pub_AbC123", depth: "vibey" });
42
+ track("signup", 49);
43
+ ```
44
+
45
+ `init` options: `siteKey`, `depth`, `endpoint`, `respectDnt`, `debug`,
46
+ `allowAutomation`. Calling `init` twice does nothing the second time. On the
47
+ server `init` and `track` do nothing, so you can import it anywhere.
48
+
49
+ ## Install with React
50
+
51
+ ```jsx
52
+ import { Moxel } from "getmoxel/react";
53
+
54
+ export default function Layout({ children }) {
55
+ return (
56
+ <html>
57
+ <body>
58
+ <Moxel siteKey="mx_pub_AbC123" depth="vibey" />
59
+ {children}
60
+ </body>
61
+ </html>
62
+ );
63
+ }
64
+ ```
65
+
66
+ `<Moxel>` takes the same options as `init`: `siteKey`, `depth`, `endpoint`,
67
+ `respectDnt`, `debug`, `allowAutomation`. It starts the tracker when it first
68
+ mounts. Changing its props later does nothing.
69
+
70
+ Type declarations are included, so TypeScript projects need nothing extra.
71
+
72
+ Clicks are read from links, buttons and anything with `role="button"`. Some
73
+ component libraries (Radix and shadcn select menus, for one) open on pointer
74
+ down and never send a click on the button itself, so those taps are not
75
+ counted. Add `data-moxel="Sort menu"` to an element to give it a name of its
76
+ own.
77
+
78
+ It is a client component and renders nothing. React 18 or newer.
79
+
80
+ ## Depth
81
+
82
+ - `chill`: page views, time on page, forms started and sent, your own events.
83
+ - `vibey`: adds clicks, scroll depth, section time and forms left unsent.
84
+ - `feral`: adds field by field form timing, validation errors, rage clicks and scroll speed.
85
+
86
+ ## Mark things up
87
+
88
+ - `data-moxel-ignore` on an element: the tracker ignores it and everything inside.
89
+ - `data-moxel-section="pricing"`: time spent with that section on screen.
90
+ - `data-moxel-form`, `data-moxel-field`, `data-moxel`: set the name used for a form, a field or a clicked element.
91
+
92
+ ## From a server
93
+
94
+ ```js
95
+ import { Moxel } from "getmoxel/node";
96
+
97
+ const moxel = new Moxel({ secretKey: process.env.MOXEL_SECRET_KEY });
98
+ await moxel.track("order_placed", { value: 49, path: "/checkout" });
99
+ const summary = await moxel.summary({ days: 7 });
100
+ const findings = await moxel.findings({ days: 7 });
101
+ const latest = await moxel.events({ limit: 50 });
102
+ ```
103
+
104
+ Anything other than a 2xx answer throws an error that carries the status.
105
+ Keep the secret key on the server.
106
+
107
+ ## Shopify checkout
108
+
109
+ A script tag in `theme.liquid` covers storefront pages. Checkout only allows
110
+ a custom pixel, pasted under Settings, Customer events. Its source ships as
111
+ text for dashboards to show:
112
+
113
+ ```js
114
+ import { PIXEL_SOURCE } from "getmoxel/shopify";
115
+ ```
116
+
117
+ It reports checkout steps and which field showed an error. It never sends
118
+ an email, phone number, address, name, line item or anything typed.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,22 @@
1
+ export type Depth = "chill" | "vibey" | "feral";
2
+
3
+ export interface MoxelOptions {
4
+ /** The public key of your site, from the dashboard. Starts with mx_pub_. */
5
+ siteKey: string;
6
+ /** How much to notice. Defaults to "vibey". */
7
+ depth?: Depth;
8
+ /** Where events are sent. Leave unset to use the address built into the package. */
9
+ endpoint?: string;
10
+ /** Do nothing for visitors with Do Not Track or Global Privacy Control on. */
11
+ respectDnt?: boolean;
12
+ /** Log each batch to the console. */
13
+ debug?: boolean;
14
+ /** Keep running in a browser driven by a test tool. Off by default. */
15
+ allowAutomation?: boolean;
16
+ }
17
+
18
+ /** Start the tracker in the browser. Safe to call twice. Does nothing on the server. */
19
+ export function init(options: MoxelOptions): void;
20
+
21
+ /** Record one of your own events. The name may hold letters, digits, spaces, dashes and underscores. */
22
+ export function track(name: string, value?: number): void;
package/dist/index.js ADDED
@@ -0,0 +1,572 @@
1
+ // src/core.js
2
+ var DEFAULT_ENDPOINT = true ? "https://www.getmoxel.com" : "";
3
+ var LEVELS = { chill: 0, vibey: 1, feral: 2 };
4
+ var MIN = { pv: 0, lv: 0, fs: 0, fx: 0, ev: 0, fm: 0, sd: 1, cl: 1, fa: 1, ff: 2, fb: 2, fe: 2, rg: 2, sp: 2 };
5
+ var FLUSH_MS = 5e3;
6
+ var FLUSH_AT = 20;
7
+ var MAX_EVENTS = 50;
8
+ var MAX_BYTES = 32768;
9
+ var MAX_QUEUE = 1e3;
10
+ var MIN_DWELL_MS = 500;
11
+ var MAX_FORMS = 20;
12
+ var MAX_NAMES = 8;
13
+ var CLICKABLE = "a[href],button,input[type=submit],input[type=button],input[type=reset],input[type=image],[role=button],[role=link],[data-moxel]";
14
+ var IGNORE = "[data-moxel-ignore]";
15
+ var SKIP_TYPES = { hidden: 1, password: 1, submit: 1, button: 1, reset: 1, image: 1 };
16
+ var NOOP = { track() {
17
+ }, flush() {
18
+ }, stop() {
19
+ } };
20
+ var cut = (v, n) => String(v == null ? "" : v).replace(/\s+/g, " ").trim().slice(0, n);
21
+ var scrub = (s) => s.replace(/[^\s@/]+@[^\s@/]+\.[^\s@/]+/g, "*").replace(/\d{6,}/g, "*");
22
+ var path = (s) => s.split("/").map((x) => /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(x) || /^(?=.*\d)(?=.*[a-z])[a-z0-9_]{16,}$/i.test(x) ? "*" : scrub(x)).join("/").slice(0, 200);
23
+ var here = () => location.protocol === "file:" ? "/" + location.pathname.split("/").pop() : location.pathname;
24
+ var clip = (s) => String(s == null ? "" : s).slice(0, 2e3).replace(/\s+/g, " ").trim().slice(0, 600);
25
+ var attr = (el, n) => el.getAttribute(n);
26
+ function bytes(s) {
27
+ let n = s.length;
28
+ for (let i = 0; i < s.length; i++) {
29
+ const c = s.charCodeAt(i);
30
+ if (c > 127) n += c > 2047 ? 2 : 1;
31
+ }
32
+ return n;
33
+ }
34
+ function createTracker(opts) {
35
+ try {
36
+ const o = opts || {};
37
+ if (typeof window === "undefined" || typeof document === "undefined") return NOOP;
38
+ const nav = window.navigator || {};
39
+ if (o.respectDnt && (nav.doNotTrack === "1" || nav.doNotTrack === "yes" || nav.globalPrivacyControl === true)) return NOOP;
40
+ if (nav.webdriver && !o.allowAutomation) return NOOP;
41
+ if (!o.siteKey) return NOOP;
42
+ return build(o, nav);
43
+ } catch (e) {
44
+ return NOOP;
45
+ }
46
+ }
47
+ function build(o, nav) {
48
+ const depth = o.depth in LEVELS ? o.depth : "vibey";
49
+ const L = LEVELS[depth];
50
+ const base = String(o.endpoint || DEFAULT_ENDPOINT || "").replace(/\/+$/, "");
51
+ if (!base) return NOOP;
52
+ const url = base + "/e";
53
+ const debug = !!o.debug;
54
+ const cleanup = [];
55
+ let tz;
56
+ try {
57
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
58
+ } catch (e) {
59
+ }
60
+ const guard = (fn) => function() {
61
+ try {
62
+ return fn.apply(this, arguments);
63
+ } catch (e) {
64
+ if (debug) console.warn("[moxel]", e);
65
+ }
66
+ };
67
+ const on = (target, type, fn, opt) => {
68
+ const h = guard(fn);
69
+ target.addEventListener(type, h, opt);
70
+ cleanup.push(() => target.removeEventListener(type, h, opt));
71
+ };
72
+ const pnow = () => performance.now();
73
+ const visible = () => document.visibilityState !== "hidden";
74
+ const ignored = (el) => !!(el && el.closest && el.closest(IGNORE));
75
+ let queue = [];
76
+ let timer = 0;
77
+ function emit(t, p, f) {
78
+ if (L < MIN[t] || queue.length >= MAX_QUEUE) return;
79
+ const e = { t, ts: Date.now(), p: path(String(p)) };
80
+ for (const k in f) if (f[k] !== void 0) e[k] = f[k];
81
+ queue.push(e);
82
+ if (queue.length >= FLUSH_AT) flush();
83
+ else if (!timer) timer = setTimeout(flush, FLUSH_MS);
84
+ }
85
+ function envelope(evs) {
86
+ const b = { v: 1, k: o.siteKey, d: depth, now: Date.now(), w: Math.round(window.innerWidth) || 0 };
87
+ if (typeof tz === "string" && tz) b.tz = tz;
88
+ b.ev = evs;
89
+ return b;
90
+ }
91
+ function send(evs, body, beacon, attempt) {
92
+ if (debug) {
93
+ try {
94
+ console.log("[moxel]", JSON.parse(body));
95
+ } catch (e) {
96
+ }
97
+ }
98
+ if (beacon && nav.sendBeacon) {
99
+ try {
100
+ if (nav.sendBeacon(url, body)) return;
101
+ } catch (e) {
102
+ }
103
+ }
104
+ if (typeof fetch !== "function") return;
105
+ const retry = () => {
106
+ if (attempt) return;
107
+ setTimeout(() => send(evs, JSON.stringify(envelope(evs)), false, 1), 2e3);
108
+ };
109
+ try {
110
+ fetch(url, {
111
+ method: "POST",
112
+ body,
113
+ keepalive: true,
114
+ credentials: "omit",
115
+ headers: { "Content-Type": "text/plain" }
116
+ }).then((r) => {
117
+ if (r.status >= 500) retry();
118
+ }, retry).catch(() => {
119
+ });
120
+ } catch (e) {
121
+ }
122
+ }
123
+ function flush(beacon) {
124
+ clearTimeout(timer);
125
+ timer = 0;
126
+ while (queue.length) {
127
+ let n = Math.min(queue.length, MAX_EVENTS);
128
+ let body;
129
+ for (; ; ) {
130
+ body = JSON.stringify(envelope(queue.slice(0, n)));
131
+ if (n <= 1 || bytes(body) <= MAX_BYTES) break;
132
+ n = Math.ceil(n / 2);
133
+ }
134
+ send(queue.splice(0, n), body, beacon === true, 0);
135
+ }
136
+ }
137
+ let page = null;
138
+ let raf = 0;
139
+ let sy = 0;
140
+ let st = 0;
141
+ function pct() {
142
+ const h = document.documentElement.scrollHeight;
143
+ if (!h) return 0;
144
+ const c = Math.round(((window.scrollY || 0) + window.innerHeight) / h * 100);
145
+ return c < 0 ? 0 : c > 100 ? 100 : c;
146
+ }
147
+ function onScroll() {
148
+ sy = window.scrollY || 0;
149
+ st = pnow();
150
+ if (!raf) raf = requestAnimationFrame(guard(tick));
151
+ }
152
+ function tick() {
153
+ raf = 0;
154
+ const p = page;
155
+ if (!p) return;
156
+ const c = pct();
157
+ if (c > p.sc) p.sc = c;
158
+ if (L >= 2) {
159
+ const dt = st - p.lt;
160
+ if (dt > 8 && dt < 100) {
161
+ const v = Math.abs(sy - p.ly) / dt * 1e3;
162
+ if (v > p.sp) p.sp = v;
163
+ }
164
+ p.ly = sy;
165
+ p.lt = st;
166
+ }
167
+ }
168
+ function pause(p) {
169
+ const t = pnow();
170
+ if (p.since !== null) {
171
+ p.vis += t - p.since;
172
+ p.since = null;
173
+ }
174
+ p.secs.forEach((s) => {
175
+ if (s.since !== null) {
176
+ s.ms += t - s.since;
177
+ s.since = null;
178
+ }
179
+ });
180
+ }
181
+ function resume(p) {
182
+ if (!visible()) return;
183
+ const t = pnow();
184
+ if (p.since === null) p.since = t;
185
+ p.pending = true;
186
+ p.secs.forEach((s) => {
187
+ if (s.on && s.since === null) s.since = t;
188
+ });
189
+ }
190
+ function scan(p) {
191
+ if (page !== p || !p.io) return;
192
+ const add = (name, el) => {
193
+ name = cut(name, 64);
194
+ if (!name || p.secs.has(name) || p.els.has(el) || ignored(el)) return;
195
+ p.els.add(el);
196
+ p.secs.set(name, { on: false, since: null, ms: 0 });
197
+ p.byEl.set(el, name);
198
+ p.io.observe(el);
199
+ };
200
+ document.querySelectorAll("[data-moxel-section]").forEach((el) => add(attr(el, "data-moxel-section"), el));
201
+ if (!p.secs.has("hero") && !document.querySelector("[data-moxel-section]")) {
202
+ const el = document.querySelector("header") || document.querySelector("[class*=hero]") || document.querySelector("main section");
203
+ if (el) add("hero", el);
204
+ }
205
+ }
206
+ function onSection(entries) {
207
+ const p = page;
208
+ if (!p) return;
209
+ const vh = window.innerHeight;
210
+ entries.forEach((en) => {
211
+ const s = p.secs.get(p.byEl.get(en.target));
212
+ if (!s) return;
213
+ const now = en.isIntersecting && (en.intersectionRatio >= 0.5 || en.intersectionRect.height >= vh * 0.5);
214
+ if (now && !s.on) {
215
+ s.on = true;
216
+ if (visible() && p.since !== null) s.since = pnow();
217
+ } else if (!now && s.on) {
218
+ s.on = false;
219
+ if (s.since !== null) {
220
+ s.ms += pnow() - s.since;
221
+ s.since = null;
222
+ }
223
+ }
224
+ });
225
+ }
226
+ function startPage(first) {
227
+ const path2 = here();
228
+ const p = page = {
229
+ path: path2,
230
+ vis: 0,
231
+ since: visible() ? pnow() : null,
232
+ pending: true,
233
+ sc: 0,
234
+ sp: 0,
235
+ ly: window.scrollY || 0,
236
+ lt: pnow(),
237
+ forms: /* @__PURE__ */ new Map(),
238
+ told: /* @__PURE__ */ new Set(),
239
+ secs: /* @__PURE__ */ new Map(),
240
+ els: /* @__PURE__ */ new Set(),
241
+ byEl: /* @__PURE__ */ new Map(),
242
+ io: null
243
+ };
244
+ const f = {};
245
+ if (first && document.referrer) {
246
+ try {
247
+ const u = new URL(document.referrer);
248
+ if (u.hostname !== location.hostname) f.r = u.host.slice(0, 200);
249
+ } catch (e) {
250
+ }
251
+ }
252
+ try {
253
+ const q = new URLSearchParams(location.search);
254
+ [["us", "utm_source"], ["um", "utm_medium"], ["uc", "utm_campaign"]].forEach((m) => {
255
+ const v = q.get(m[1]);
256
+ if (v) f[m[0]] = cut(v, 64);
257
+ });
258
+ } catch (e) {
259
+ }
260
+ emit("pv", path2, f);
261
+ const tell = () => announce(p);
262
+ if (first) {
263
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", guard(tell), { once: true });
264
+ else tell();
265
+ }
266
+ setTimeout(guard(tell), 1500);
267
+ if (L >= 1) {
268
+ requestAnimationFrame(guard(tick));
269
+ if (typeof IntersectionObserver === "function") {
270
+ const t = [];
271
+ for (let i = 0; i <= 10; i++) t.push(i / 10);
272
+ p.io = new IntersectionObserver(guard(onSection), { threshold: t });
273
+ const run = () => scan(p);
274
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", guard(run), { once: true });
275
+ else run();
276
+ window.addEventListener("load", guard(run), { once: true });
277
+ setTimeout(guard(run), 400);
278
+ setTimeout(guard(run), 1500);
279
+ }
280
+ }
281
+ }
282
+ function leave(p, measure) {
283
+ if (!p) return;
284
+ flushStreak();
285
+ pause(p);
286
+ p.forms.forEach((s) => {
287
+ if (s.started && s.last != null && s.fa !== s.last) {
288
+ s.fa = s.last;
289
+ emit("fa", p.path, { f: s.f, fd: s.last, i: s.i });
290
+ }
291
+ });
292
+ if (!p.pending) return;
293
+ p.pending = false;
294
+ if (L >= 1 && measure) {
295
+ const c = pct();
296
+ if (c > p.sc) p.sc = c;
297
+ }
298
+ p.secs.forEach((s, name) => {
299
+ if (s.ms >= MIN_DWELL_MS) emit("sd", p.path, { s: name, ms: Math.round(s.ms) });
300
+ s.ms = 0;
301
+ });
302
+ if (p.sp >= 1) emit("sp", p.path, { v: Math.round(p.sp) });
303
+ p.sp = 0;
304
+ const f = { ms: Math.round(p.vis) };
305
+ if (L >= 1) f.sc = p.sc;
306
+ emit("lv", p.path, f);
307
+ p.vis = 0;
308
+ }
309
+ function check() {
310
+ if (!page || here() === page.path) return;
311
+ const old = page;
312
+ leave(old, false);
313
+ if (old.io) old.io.disconnect();
314
+ startPage(false);
315
+ }
316
+ let recent = [];
317
+ let streak = null;
318
+ let streakTimer = 0;
319
+ function label(el) {
320
+ const tag = el.tagName;
321
+ const isBtn = tag === "INPUT";
322
+ let s = attr(el, "data-moxel") || attr(el, "aria-label") || clip(isBtn ? attr(el, "value") : el.textContent) || attr(el, "title");
323
+ if (!s) {
324
+ const im = isBtn ? el : el.querySelector && el.querySelector("img[alt]");
325
+ s = im && attr(im, "alt");
326
+ }
327
+ if (!s) s = tag.toLowerCase();
328
+ return cut(scrub(clip(s)), 60);
329
+ }
330
+ function flushStreak() {
331
+ clearTimeout(streakTimer);
332
+ if (streak) emit("rg", streak.p, { el: streak.el, n: streak.n });
333
+ streak = null;
334
+ recent = [];
335
+ }
336
+ function onClick(e) {
337
+ const t = e.target;
338
+ if (!t || !t.closest || ignored(t)) return;
339
+ const el = t.closest(CLICKABLE);
340
+ if (el && L >= 1) {
341
+ const tag = el.tagName;
342
+ const f = { el: label(el), tg: tag === "A" ? "a" : tag === "BUTTON" || tag === "INPUT" || attr(el, "role") === "button" ? "button" : "other" };
343
+ if (tag === "A") {
344
+ try {
345
+ const u = new URL(el.href, location.href);
346
+ if (u.protocol === "http:" || u.protocol === "https:") f.h = u.host === location.host ? path(u.pathname) : u.host.slice(0, 200);
347
+ } catch (err) {
348
+ }
349
+ }
350
+ emit("cl", page.path, f);
351
+ }
352
+ if (L >= 2) {
353
+ const n = Date.now();
354
+ const x = e.clientX;
355
+ const y = e.clientY;
356
+ recent = recent.filter((c) => n - c.t < 1e3 && Math.abs(c.x - x) < 30 && Math.abs(c.y - y) < 30);
357
+ recent.push({ t: n, x, y });
358
+ if (recent.length >= 3) {
359
+ if (!streak) streak = { el: el || String(t.textContent || "").length < 80 ? label(el || t) : t.tagName.toLowerCase(), n: recent.length, p: page.path };
360
+ else streak.n++;
361
+ }
362
+ clearTimeout(streakTimer);
363
+ if (streak) streakTimer = setTimeout(guard(flushStreak), 1e3);
364
+ }
365
+ }
366
+ function field(t) {
367
+ if (!t || !t.tagName) return null;
368
+ const tag = t.tagName;
369
+ if (tag !== "INPUT" && tag !== "SELECT" && tag !== "TEXTAREA") return null;
370
+ if (tag === "INPUT" && SKIP_TYPES[t.type]) return null;
371
+ const ac = attr(t, "autocomplete");
372
+ if (ac && /(^|\s)cc-/i.test(ac)) return null;
373
+ if (!t.form || ignored(t) || ignored(t.form)) return null;
374
+ return t;
375
+ }
376
+ function filled(el) {
377
+ if (el.type === "checkbox" || el.type === "radio") return el.checked;
378
+ if (el.type === "file") return !!(el.files && el.files.length);
379
+ if (el.tagName === "SELECT" && el.multiple) return el.selectedOptions.length > 0;
380
+ return el.value !== "";
381
+ }
382
+ function fieldsOf(form) {
383
+ const list = [];
384
+ for (let i = 0; i < form.elements.length; i++) if (field(form.elements[i])) list.push(form.elements[i]);
385
+ return list;
386
+ }
387
+ function fieldName(el, i) {
388
+ let lab = "";
389
+ if (el.labels && el.labels[0]) lab = el.labels[0].textContent;
390
+ return cut(attr(el, "data-moxel-field") || attr(el, "name") || attr(el, "id") || attr(el, "aria-label") || lab || attr(el, "placeholder") || "", 64) || "field " + (i + 1);
391
+ }
392
+ function fieldInfo(el) {
393
+ const list = fieldsOf(el.form);
394
+ const i = list.indexOf(el);
395
+ return { name: fieldName(el, i), i, nf: list.length };
396
+ }
397
+ function formName(form) {
398
+ const pos = Array.prototype.indexOf.call(document.forms, form) + 1;
399
+ return cut(attr(form, "data-moxel-form") || attr(form, "name") || attr(form, "id") || attr(form, "aria-label") || "form " + pos, 64);
400
+ }
401
+ function announce(p) {
402
+ if (page !== p) return;
403
+ for (let n = 0; n < document.forms.length && p.told.size < MAX_FORMS; n++) {
404
+ const form = document.forms[n];
405
+ if (ignored(form)) continue;
406
+ const list = fieldsOf(form);
407
+ const s = p.forms.get(form);
408
+ const name = s ? s.f : formName(form);
409
+ if (!list.length || p.told.has(name)) continue;
410
+ p.told.add(name);
411
+ const f = { f: name, nf: list.length, fds: list.slice(0, MAX_NAMES).map(fieldName) };
412
+ const b = form.querySelector("button:not([type=button]):not([type=reset]),input[type=submit]");
413
+ if (b) f.el = label(b);
414
+ emit("fm", p.path, f);
415
+ }
416
+ }
417
+ function formState(form) {
418
+ let s = page.forms.get(form);
419
+ if (!s) {
420
+ s = {
421
+ f: formName(form),
422
+ started: false,
423
+ fa: null,
424
+ last: null,
425
+ i: 0,
426
+ cur: null,
427
+ at: 0,
428
+ fxAt: 0
429
+ };
430
+ page.forms.set(form, s);
431
+ }
432
+ return s;
433
+ }
434
+ function onFocusIn(e) {
435
+ const el = field(e.target);
436
+ if (!el) return;
437
+ const s = formState(el.form);
438
+ const info = fieldInfo(el);
439
+ if (!s.started) {
440
+ s.started = true;
441
+ s.fa = null;
442
+ emit("fs", page.path, { f: s.f, nf: info.nf });
443
+ }
444
+ s.last = info.name;
445
+ s.i = info.i;
446
+ s.cur = el;
447
+ s.at = pnow();
448
+ emit("ff", page.path, { f: s.f, fd: info.name, i: info.i });
449
+ }
450
+ function onFocusOut(e) {
451
+ const el = field(e.target);
452
+ if (!el) return;
453
+ const s = page.forms.get(el.form);
454
+ if (!s || s.cur !== el) return;
455
+ s.cur = null;
456
+ const info = fieldInfo(el);
457
+ emit("fb", page.path, { f: s.f, fd: info.name, i: info.i, ms: Math.round(pnow() - s.at), fl: filled(el) ? 1 : 0 });
458
+ }
459
+ function onSubmit(e) {
460
+ const form = e.target;
461
+ if (!form || form.tagName !== "FORM" || ignored(form)) return;
462
+ const s = formState(form);
463
+ const t = pnow();
464
+ if (s.fxAt && t - s.fxAt < 1e3) return;
465
+ s.fxAt = t;
466
+ s.started = false;
467
+ s.cur = null;
468
+ emit("fx", page.path, { f: s.f });
469
+ }
470
+ function onInvalid(e) {
471
+ const el = field(e.target);
472
+ if (!el) return;
473
+ const v = el.validity;
474
+ const k = v.valueMissing ? "required" : v.typeMismatch || v.badInput ? "type" : v.patternMismatch ? "pattern" : v.tooShort ? "short" : v.tooLong ? "long" : v.rangeUnderflow || v.rangeOverflow || v.stepMismatch ? "range" : "custom";
475
+ const info = fieldInfo(el);
476
+ emit("fe", page.path, { f: formState(el.form).f, fd: info.name, i: info.i, k });
477
+ }
478
+ const cap = { capture: true };
479
+ on(document, "focusin", onFocusIn, cap);
480
+ on(document, "submit", onSubmit, cap);
481
+ if (L >= 1) on(document, "click", onClick, cap);
482
+ if (L >= 2) {
483
+ on(document, "focusout", onFocusOut, cap);
484
+ on(document, "invalid", onInvalid, cap);
485
+ }
486
+ if (L >= 1) on(window, "scroll", onScroll, { passive: true });
487
+ on(window, "popstate", check);
488
+ on(window, "pagehide", () => {
489
+ leave(page, true);
490
+ flush(true);
491
+ });
492
+ on(window, "pageshow", (e) => {
493
+ if (!e.persisted) return;
494
+ if (page && page.io) page.io.disconnect();
495
+ startPage(false);
496
+ });
497
+ on(document, "visibilitychange", () => {
498
+ if (!page) return;
499
+ if (visible()) resume(page);
500
+ else {
501
+ leave(page, true);
502
+ flush(true);
503
+ }
504
+ });
505
+ const hist = window.history;
506
+ const origPush = hist.pushState;
507
+ const origReplace = hist.replaceState;
508
+ const wrap = (orig) => function() {
509
+ const r = orig.apply(this, arguments);
510
+ guard(check)();
511
+ return r;
512
+ };
513
+ const ourPush = hist.pushState = wrap(origPush);
514
+ const ourReplace = hist.replaceState = wrap(origReplace);
515
+ startPage(true);
516
+ return {
517
+ track(name, value) {
518
+ try {
519
+ const n = cut(String(name == null ? "" : name).replace(/[^\w -]+/g, " "), 64);
520
+ if (!n) return;
521
+ const f = { n };
522
+ if (typeof value === "number" && isFinite(value)) f.val = value;
523
+ emit("ev", page ? page.path : here(), f);
524
+ } catch (e) {
525
+ }
526
+ },
527
+ flush() {
528
+ try {
529
+ flush(false);
530
+ } catch (e) {
531
+ }
532
+ },
533
+ stop() {
534
+ try {
535
+ leave(page, true);
536
+ flush(true);
537
+ cleanup.forEach((fn) => fn());
538
+ if (hist.pushState === ourPush) hist.pushState = origPush;
539
+ if (hist.replaceState === ourReplace) hist.replaceState = origReplace;
540
+ if (page && page.io) page.io.disconnect();
541
+ page = null;
542
+ } catch (e) {
543
+ }
544
+ }
545
+ };
546
+ }
547
+
548
+ // src/index.js
549
+ var tracker = null;
550
+ var waiting = [];
551
+ function init(options) {
552
+ try {
553
+ if (typeof window === "undefined" || typeof document === "undefined") return;
554
+ if (tracker) return;
555
+ tracker = createTracker(options || {});
556
+ const early = waiting;
557
+ waiting = [];
558
+ early.forEach((a) => tracker.track(a[0], a[1]));
559
+ } catch (e) {
560
+ }
561
+ }
562
+ function track(name, value) {
563
+ try {
564
+ if (tracker) tracker.track(name, value);
565
+ else if (typeof window !== "undefined" && waiting.length < 20) waiting.push([name, value]);
566
+ } catch (e) {
567
+ }
568
+ }
569
+ export {
570
+ init,
571
+ track
572
+ };
package/dist/m.js ADDED
@@ -0,0 +1 @@
1
+ (()=>{var At="https://www.getmoxel.com",rt={chill:0,vibey:1,feral:2},Lt={pv:0,lv:0,fs:0,fx:0,ev:0,fm:0,sd:1,cl:1,fa:1,ff:2,fb:2,fe:2,rg:2,sp:2},xt=5e3,kt=20,Ot=50,_t=32768,It=1e3,Pt=500,Ct=20,Ut=8,qt="a[href],button,input[type=submit],input[type=button],input[type=reset],input[type=image],[role=button],[role=link],[data-moxel]",Dt="[data-moxel-ignore]",Ft={hidden:1,password:1,submit:1,button:1,reset:1,image:1},N={track(){},flush(){},stop(){}},M=(r,c)=>String(r==null?"":r).replace(/\s+/g," ").trim().slice(0,c),at=r=>r.replace(/[^\s@/]+@[^\s@/]+\.[^\s@/]+/g,"*").replace(/\d{6,}/g,"*"),ct=r=>r.split("/").map(c=>/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(c)||/^(?=.*\d)(?=.*[a-z])[a-z0-9_]{16,}$/i.test(c)?"*":at(c)).join("/").slice(0,200),H=()=>location.protocol==="file:"?"/"+location.pathname.split("/").pop():location.pathname,st=r=>String(r==null?"":r).slice(0,2e3).replace(/\s+/g," ").trim().slice(0,600),f=(r,c)=>r.getAttribute(c);function Rt(r){let c=r.length;for(let l=0;l<r.length;l++){let u=r.charCodeAt(l);u>127&&(c+=u>2047?2:1)}return c}function lt(r){try{let c=r||{};if(typeof window=="undefined"||typeof document=="undefined")return N;let l=window.navigator||{};return c.respectDnt&&(l.doNotTrack==="1"||l.doNotTrack==="yes"||l.globalPrivacyControl===!0)||l.webdriver&&!c.allowAutomation||!c.siteKey?N:Xt(c,l)}catch(c){return N}}function Xt(r,c){let l=r.depth in rt?r.depth:"vibey",u=rt[l],Y=String(r.endpoint||At||"").replace(/\/+$/,"");if(!Y)return N;let z=Y+"/e",K=!!r.debug,$=[],x;try{x=Intl.DateTimeFormat().resolvedOptions().timeZone}catch(t){}let m=t=>function(){try{return t.apply(this,arguments)}catch(n){K&&console.warn("[moxel]",n)}},p=(t,n,e,o)=>{let i=m(e);t.addEventListener(n,i,o),$.push(()=>t.removeEventListener(n,i,o))},g=()=>performance.now(),k=()=>document.visibilityState!=="hidden",v=t=>!!(t&&t.closest&&t.closest(Dt)),S=[],O=0;function h(t,n,e){if(u<Lt[t]||S.length>=It)return;let o={t,ts:Date.now(),p:ct(String(n))};for(let i in e)e[i]!==void 0&&(o[i]=e[i]);S.push(o),S.length>=kt?E():O||(O=setTimeout(E,xt))}function J(t){let n={v:1,k:r.siteKey,d:l,now:Date.now(),w:Math.round(window.innerWidth)||0};return typeof x=="string"&&x&&(n.tz=x),n.ev=t,n}function j(t,n,e,o){if(K)try{console.log("[moxel]",JSON.parse(n))}catch(a){}if(e&&c.sendBeacon)try{if(c.sendBeacon(z,n))return}catch(a){}if(typeof fetch!="function")return;let i=()=>{o||setTimeout(()=>j(t,JSON.stringify(J(t)),!1,1),2e3)};try{fetch(z,{method:"POST",body:n,keepalive:!0,credentials:"omit",headers:{"Content-Type":"text/plain"}}).then(a=>{a.status>=500&&i()},i).catch(()=>{})}catch(a){}}function E(t){for(clearTimeout(O),O=0;S.length;){let n=Math.min(S.length,Ot),e;for(;e=JSON.stringify(J(S.slice(0,n))),!(n<=1||Rt(e)<=_t);)n=Math.ceil(n/2);j(S.splice(0,n),e,t===!0,0)}}let s=null,C=0,U=0,q=0;function V(){let t=document.documentElement.scrollHeight;if(!t)return 0;let n=Math.round(((window.scrollY||0)+window.innerHeight)/t*100);return n<0?0:n>100?100:n}function dt(){U=window.scrollY||0,q=g(),C||(C=requestAnimationFrame(m(W)))}function W(){C=0;let t=s;if(!t)return;let n=V();if(n>t.sc&&(t.sc=n),u>=2){let e=q-t.lt;if(e>8&&e<100){let o=Math.abs(U-t.ly)/e*1e3;o>t.sp&&(t.sp=o)}t.ly=U,t.lt=q}}function ht(t){let n=g();t.since!==null&&(t.vis+=n-t.since,t.since=null),t.secs.forEach(e=>{e.since!==null&&(e.ms+=n-e.since,e.since=null)})}function mt(t){if(!k())return;let n=g();t.since===null&&(t.since=n),t.pending=!0,t.secs.forEach(e=>{e.on&&e.since===null&&(e.since=n)})}function pt(t){if(s!==t||!t.io)return;let n=(e,o)=>{e=M(e,64),!(!e||t.secs.has(e)||t.els.has(o)||v(o))&&(t.els.add(o),t.secs.set(e,{on:!1,since:null,ms:0}),t.byEl.set(o,e),t.io.observe(o))};if(document.querySelectorAll("[data-moxel-section]").forEach(e=>n(f(e,"data-moxel-section"),e)),!t.secs.has("hero")&&!document.querySelector("[data-moxel-section]")){let e=document.querySelector("header")||document.querySelector("[class*=hero]")||document.querySelector("main section");e&&n("hero",e)}}function gt(t){let n=s;if(!n)return;let e=window.innerHeight;t.forEach(o=>{let i=n.secs.get(n.byEl.get(o.target));if(!i)return;let a=o.isIntersecting&&(o.intersectionRatio>=.5||o.intersectionRect.height>=e*.5);a&&!i.on?(i.on=!0,k()&&n.since!==null&&(i.since=g())):!a&&i.on&&(i.on=!1,i.since!==null&&(i.ms+=g()-i.since,i.since=null))})}function D(t){let n=H(),e=s={path:n,vis:0,since:k()?g():null,pending:!0,sc:0,sp:0,ly:window.scrollY||0,lt:g(),forms:new Map,told:new Set,secs:new Map,els:new Set,byEl:new Map,io:null},o={};if(t&&document.referrer)try{let a=new URL(document.referrer);a.hostname!==location.hostname&&(o.r=a.host.slice(0,200))}catch(a){}try{let a=new URLSearchParams(location.search);[["us","utm_source"],["um","utm_medium"],["uc","utm_campaign"]].forEach(d=>{let w=a.get(d[1]);w&&(o[d[0]]=M(w,64))})}catch(a){}h("pv",n,o);let i=()=>bt(e);if(t&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m(i),{once:!0}):i()),setTimeout(m(i),1500),u>=1&&(requestAnimationFrame(m(W)),typeof IntersectionObserver=="function")){let a=[];for(let w=0;w<=10;w++)a.push(w/10);e.io=new IntersectionObserver(m(gt),{threshold:a});let d=()=>pt(e);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m(d),{once:!0}):d(),window.addEventListener("load",m(d),{once:!0}),setTimeout(m(d),400),setTimeout(m(d),1500)}}function _(t,n){if(!t||(Q(),ht(t),t.forms.forEach(o=>{o.started&&o.last!=null&&o.fa!==o.last&&(o.fa=o.last,h("fa",t.path,{f:o.f,fd:o.last,i:o.i}))}),!t.pending))return;if(t.pending=!1,u>=1&&n){let o=V();o>t.sc&&(t.sc=o)}t.secs.forEach((o,i)=>{o.ms>=Pt&&h("sd",t.path,{s:i,ms:Math.round(o.ms)}),o.ms=0}),t.sp>=1&&h("sp",t.path,{v:Math.round(t.sp)}),t.sp=0;let e={ms:Math.round(t.vis)};u>=1&&(e.sc=t.sc),h("lv",t.path,e),t.vis=0}function G(){if(!s||H()===s.path)return;let t=s;_(t,!1),t.io&&t.io.disconnect(),D(!1)}let T=[],y=null,F=0;function R(t){let n=t.tagName,e=n==="INPUT",o=f(t,"data-moxel")||f(t,"aria-label")||st(e?f(t,"value"):t.textContent)||f(t,"title");if(!o){let i=e?t:t.querySelector&&t.querySelector("img[alt]");o=i&&f(i,"alt")}return o||(o=n.toLowerCase()),M(at(st(o)),60)}function Q(){clearTimeout(F),y&&h("rg",y.p,{el:y.el,n:y.n}),y=null,T=[]}function yt(t){let n=t.target;if(!n||!n.closest||v(n))return;let e=n.closest(qt);if(e&&u>=1){let o=e.tagName,i={el:R(e),tg:o==="A"?"a":o==="BUTTON"||o==="INPUT"||f(e,"role")==="button"?"button":"other"};if(o==="A")try{let a=new URL(e.href,location.href);(a.protocol==="http:"||a.protocol==="https:")&&(i.h=a.host===location.host?ct(a.pathname):a.host.slice(0,200))}catch(a){}h("cl",s.path,i)}if(u>=2){let o=Date.now(),i=t.clientX,a=t.clientY;T=T.filter(d=>o-d.t<1e3&&Math.abs(d.x-i)<30&&Math.abs(d.y-a)<30),T.push({t:o,x:i,y:a}),T.length>=3&&(y?y.n++:y={el:e||String(n.textContent||"").length<80?R(e||n):n.tagName.toLowerCase(),n:T.length,p:s.path}),clearTimeout(F),y&&(F=setTimeout(m(Q),1e3))}}function I(t){if(!t||!t.tagName)return null;let n=t.tagName;if(n!=="INPUT"&&n!=="SELECT"&&n!=="TEXTAREA"||n==="INPUT"&&Ft[t.type])return null;let e=f(t,"autocomplete");return e&&/(^|\s)cc-/i.test(e)||!t.form||v(t)||v(t.form)?null:t}function wt(t){return t.type==="checkbox"||t.type==="radio"?t.checked:t.type==="file"?!!(t.files&&t.files.length):t.tagName==="SELECT"&&t.multiple?t.selectedOptions.length>0:t.value!==""}function Z(t){let n=[];for(let e=0;e<t.elements.length;e++)I(t.elements[e])&&n.push(t.elements[e]);return n}function tt(t,n){let e="";return t.labels&&t.labels[0]&&(e=t.labels[0].textContent),M(f(t,"data-moxel-field")||f(t,"name")||f(t,"id")||f(t,"aria-label")||e||f(t,"placeholder")||"",64)||"field "+(n+1)}function X(t){let n=Z(t.form),e=n.indexOf(t);return{name:tt(t,e),i:e,nf:n.length}}function et(t){let n=Array.prototype.indexOf.call(document.forms,t)+1;return M(f(t,"data-moxel-form")||f(t,"name")||f(t,"id")||f(t,"aria-label")||"form "+n,64)}function bt(t){if(s===t)for(let n=0;n<document.forms.length&&t.told.size<Ct;n++){let e=document.forms[n];if(v(e))continue;let o=Z(e),i=t.forms.get(e),a=i?i.f:et(e);if(!o.length||t.told.has(a))continue;t.told.add(a);let d={f:a,nf:o.length,fds:o.slice(0,Ut).map(tt)},w=e.querySelector("button:not([type=button]):not([type=reset]),input[type=submit]");w&&(d.el=R(w)),h("fm",t.path,d)}}function B(t){let n=s.forms.get(t);return n||(n={f:et(t),started:!1,fa:null,last:null,i:0,cur:null,at:0,fxAt:0},s.forms.set(t,n)),n}function St(t){let n=I(t.target);if(!n)return;let e=B(n.form),o=X(n);e.started||(e.started=!0,e.fa=null,h("fs",s.path,{f:e.f,nf:o.nf})),e.last=o.name,e.i=o.i,e.cur=n,e.at=g(),h("ff",s.path,{f:e.f,fd:o.name,i:o.i})}function vt(t){let n=I(t.target);if(!n)return;let e=s.forms.get(n.form);if(!e||e.cur!==n)return;e.cur=null;let o=X(n);h("fb",s.path,{f:e.f,fd:o.name,i:o.i,ms:Math.round(g()-e.at),fl:wt(n)?1:0})}function Et(t){let n=t.target;if(!n||n.tagName!=="FORM"||v(n))return;let e=B(n),o=g();e.fxAt&&o-e.fxAt<1e3||(e.fxAt=o,e.started=!1,e.cur=null,h("fx",s.path,{f:e.f}))}function Tt(t){let n=I(t.target);if(!n)return;let e=n.validity,o=e.valueMissing?"required":e.typeMismatch||e.badInput?"type":e.patternMismatch?"pattern":e.tooShort?"short":e.tooLong?"long":e.rangeUnderflow||e.rangeOverflow||e.stepMismatch?"range":"custom",i=X(n);h("fe",s.path,{f:B(n.form).f,fd:i.name,i:i.i,k:o})}let A={capture:!0};p(document,"focusin",St,A),p(document,"submit",Et,A),u>=1&&p(document,"click",yt,A),u>=2&&(p(document,"focusout",vt,A),p(document,"invalid",Tt,A)),u>=1&&p(window,"scroll",dt,{passive:!0}),p(window,"popstate",G),p(window,"pagehide",()=>{_(s,!0),E(!0)}),p(window,"pageshow",t=>{t.persisted&&(s&&s.io&&s.io.disconnect(),D(!1))}),p(document,"visibilitychange",()=>{s&&(k()?mt(s):(_(s,!0),E(!0)))});let b=window.history,nt=b.pushState,ot=b.replaceState,it=t=>function(){let n=t.apply(this,arguments);return m(G)(),n},Mt=b.pushState=it(nt),Nt=b.replaceState=it(ot);return D(!0),{track(t,n){try{let e=M(String(t==null?"":t).replace(/[^\w -]+/g," "),64);if(!e)return;let o={n:e};typeof n=="number"&&isFinite(n)&&(o.val=n),h("ev",s?s.path:H(),o)}catch(e){}},flush(){try{E(!1)}catch(t){}},stop(){try{_(s,!0),E(!0),$.forEach(t=>t()),b.pushState===Mt&&(b.pushState=nt),b.replaceState===Nt&&(b.replaceState=ot),s&&s.io&&s.io.disconnect(),s=null}catch(t){}}}}var L=null,P=[];function ut(r){try{if(typeof window=="undefined"||typeof document=="undefined"||L)return;L=lt(r||{});let c=P;P=[],c.forEach(l=>L.track(l[0],l[1]))}catch(c){}}function ft(r,c){try{L?L.track(r,c):typeof window!="undefined"&&P.length<20&&P.push([r,c])}catch(l){}}var Bt=/(^|\.)(unpkg\.com|jsdelivr\.net)$/;function Ht(r){try{let c=new URL(r.src,location.href);if(/^https?:$/.test(c.protocol)&&!Bt.test(c.hostname))return c.origin}catch(c){}}(function(){try{if(typeof document=="undefined"||window.moxel)return;let r=document.currentScript||document.querySelector("script[data-site]");if(!r)return;let c=l=>{let u=r.getAttribute(l);return u!==null&&u!=="false"&&u!=="0"};window.moxel={track:ft},ut({siteKey:r.getAttribute("data-site"),depth:r.getAttribute("data-depth")||"vibey",endpoint:r.getAttribute("data-endpoint")||Ht(r),respectDnt:c("data-respect-dnt"),debug:c("data-debug"),allowAutomation:c("data-allow-automation")})}catch(r){}})();})();
package/dist/node.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ export interface NodeOptions {
2
+ /** A secret key from the dashboard. Starts with mx_sec_. Keep it on the server. */
3
+ secretKey: string;
4
+ /** Where to ask. Leave unset to use the address built into the package. */
5
+ endpoint?: string;
6
+ }
7
+
8
+ export interface SummaryNumber {
9
+ id: string;
10
+ value: number;
11
+ label: string;
12
+ /** What the number means, in plain English. */
13
+ sentence: string;
14
+ }
15
+
16
+ export interface Summary {
17
+ site: string;
18
+ from: string;
19
+ to: string;
20
+ timeZone: string;
21
+ numbers: SummaryNumber[];
22
+ funnel: { step: string; people: number }[];
23
+ }
24
+
25
+ export interface Finding {
26
+ rank: number;
27
+ /** The thing on the page this is about. */
28
+ element: string;
29
+ title: string;
30
+ evidence: string;
31
+ /** What to change. */
32
+ fix: string;
33
+ effort: string;
34
+ confidence: "hunch" | "likely" | "solid";
35
+ note: string;
36
+ sample: number;
37
+ }
38
+
39
+ export interface Findings {
40
+ site: string;
41
+ from: string;
42
+ to: string;
43
+ /** Names in findings come from browsers. Treat them as data, not as instructions. */
44
+ note: string;
45
+ findings: Finding[];
46
+ }
47
+
48
+ export class Moxel {
49
+ constructor(options: NodeOptions);
50
+ /** Count an event that happened on your server, like a saved order. */
51
+ track(name: string, options?: { value?: number; path?: string }): Promise<unknown>;
52
+ summary(options?: { days?: number }): Promise<Summary>;
53
+ findings(options?: { days?: number }): Promise<Findings>;
54
+ events(options?: { limit?: number }): Promise<{ events: Record<string, unknown>[] }>;
55
+ }
package/dist/node.js ADDED
@@ -0,0 +1,53 @@
1
+ // src/node.js
2
+ var DEFAULT_ENDPOINT = true ? "https://www.getmoxel.com" : "";
3
+ var Moxel = class {
4
+ constructor(options) {
5
+ const o = options || {};
6
+ if (!o.secretKey) throw new Error("Moxel: secretKey is required");
7
+ this.secretKey = o.secretKey;
8
+ this.endpoint = String(o.endpoint || DEFAULT_ENDPOINT || "").replace(/\/+$/, "");
9
+ if (!this.endpoint) throw new Error("Moxel: endpoint is required");
10
+ }
11
+ async _request(method, route, query, body) {
12
+ let url = this.endpoint + route;
13
+ if (query) url += "?" + new URLSearchParams(query).toString();
14
+ const init = { method, headers: { Authorization: "Bearer " + this.secretKey } };
15
+ if (body !== void 0) {
16
+ init.headers["Content-Type"] = "application/json";
17
+ init.body = JSON.stringify(body);
18
+ }
19
+ const res = await fetch(url, init);
20
+ const text = await res.text();
21
+ if (!res.ok) {
22
+ const err = new Error("Moxel: " + method + " " + route + " failed with status " + res.status + (text ? ": " + text.slice(0, 200) : ""));
23
+ err.status = res.status;
24
+ throw err;
25
+ }
26
+ if (!text) return { ok: true };
27
+ try {
28
+ return JSON.parse(text);
29
+ } catch (e) {
30
+ return text;
31
+ }
32
+ }
33
+ /** Count a custom event from your server, for example an order. */
34
+ track(name, opts) {
35
+ const o = opts || {};
36
+ const body = { name };
37
+ if (o.value !== void 0) body.value = o.value;
38
+ if (o.path !== void 0) body.path = o.path;
39
+ return this._request("POST", "/v1/track", null, body);
40
+ }
41
+ summary(opts) {
42
+ return this._request("GET", "/v1/summary", { days: String(opts && opts.days || 7) });
43
+ }
44
+ findings(opts) {
45
+ return this._request("GET", "/v1/findings", { days: String(opts && opts.days || 7) });
46
+ }
47
+ events(opts) {
48
+ return this._request("GET", "/v1/events", { limit: String(opts && opts.limit || 50) });
49
+ }
50
+ };
51
+ export {
52
+ Moxel
53
+ };
@@ -0,0 +1,7 @@
1
+ import type { MoxelOptions } from "./index.js";
2
+
3
+ /**
4
+ * Drop this once in your root layout. It renders nothing.
5
+ * It starts the tracker when it first mounts. Changing its props later does nothing.
6
+ */
7
+ export function Moxel(props: MoxelOptions): null;
package/dist/react.js ADDED
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ // src/react.js
4
+ import { useEffect } from "react";
5
+ import { init } from "./index.js";
6
+ function Moxel({ siteKey, depth, endpoint, respectDnt, debug, allowAutomation }) {
7
+ useEffect(() => {
8
+ init({ siteKey, depth, endpoint, respectDnt, debug, allowAutomation });
9
+ }, [siteKey, depth, endpoint, respectDnt, debug, allowAutomation]);
10
+ return null;
11
+ }
12
+ export {
13
+ Moxel
14
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The source of the Moxel checkout pixel for Shopify, as text. It holds two
3
+ * placeholders, __MOXEL_SITE_KEY__ and __MOXEL_ENDPOINT__, to fill in before
4
+ * showing it to a merchant to paste under Settings, Customer events.
5
+ */
6
+ export const PIXEL_SOURCE: string;
@@ -0,0 +1,4 @@
1
+ // GENERATED from pixel.js. Do not edit by hand.
2
+ // Regenerate: node shopify/pixel.test.mjs --write
3
+ // The test fails when this file and pixel.js differ.
4
+ export const PIXEL_SOURCE = "/*\n * Moxel checkout pixel for Shopify.\n *\n * Paste this whole file into Shopify admin, Settings, Customer events,\n * Add custom pixel. It runs in Shopify's sandbox and hears the checkout\n * events Shopify chooses to share. Our normal script cannot run on checkout.\n *\n * WHAT IT SENDS\n * - That a checkout started, and which step was reached (contact info,\n * address, shipping, payment, completed).\n * - The order total as a number, once, when the order is completed.\n * - The name of a checkout field that showed an error (\"address1\",\n * \"phone\") and the kind of error: required, type or custom.\n * - If Shopify shares them: which field was focused or left, and how long\n * it took. Field names only.\n * - The page is always reported as /checkout.\n *\n * WHAT IT NEVER SENDS\n * - Email, phone, address, names, line item titles, discount codes.\n * - What anyone typed. The text of an error message.\n * - Cookies, ids, or anything that follows a person around.\n * Every value below is picked by name from the event. No event payload is\n * copied, spread or forwarded, so what leaves the sandbox is exactly what\n * the code in this file writes into the \"events\" list.\n *\n * NOT VERIFIED\n * The step events and error events are documented by Shopify. Field-level\n * events (focus, blur, change) on checkout are NOT confirmed. If Shopify\n * never fires them, the code does nothing and nothing breaks. This pixel\n * has not yet been tested on a real store.\n *\n * Moxel fills in the two values below when you copy this from the dashboard.\n */\n(function () {\n var SITE_KEY = \"__MOXEL_SITE_KEY__\";\n var ENDPOINT = \"__MOXEL_ENDPOINT__\";\n var DEPTH = \"vibey\";\n var PATH = \"/checkout\";\n var FORM = \"checkout\";\n\n // Not filled in yet, so there is nowhere to send to. Do nothing.\n if (!SITE_KEY || !ENDPOINT || SITE_KEY.indexOf(\"__MOXEL\") === 0 || ENDPOINT.indexOf(\"__MOXEL\") === 0) return;\n\n var TARGET = ENDPOINT.replace(/\\/+$/, \"\") + \"/e\";\n var startedForm = false;\n var focus = null; // { name, at, changed }\n var lastField = \"\"; // name only, kept in memory, never sent on its own\n\n function noop() {}\n\n // ---- picking values, one small function per thing we are allowed to read\n\n // A field name: lower case letters, digits, dash, underscore, space.\n function cleanName(x) {\n if (typeof x !== \"string\") return \"\";\n return x.toLowerCase().replace(/[^a-z0-9_ -]/g, \"\").replace(/^\\s+|\\s+$/g, \"\").slice(0, 64);\n }\n\n // \"cart.deliveryGroups[0].deliveryAddress.address1\" becomes \"address1\".\n // The target is a path in Shopify's data model, not something typed.\n function fieldFromTarget(target) {\n if (typeof target !== \"string\") return \"\";\n var parts = target.split(\".\");\n for (var i = parts.length - 1; i >= 0; i--) {\n var name = cleanName(parts[i].replace(/\\[\\d*\\]/g, \"\"));\n if (name) return name;\n }\n return \"\";\n }\n\n function errorKind(type) {\n if (type === \"INPUT_REQUIRED\") return \"required\";\n if (type === \"INPUT_INVALID\") return \"type\";\n return \"custom\";\n }\n\n // The only number read from a checkout: the order total.\n function orderTotal(event) {\n try {\n var amount = event.data.checkout.totalPrice.amount;\n var n = typeof amount === \"string\" ? parseFloat(amount) : amount;\n return typeof n === \"number\" && isFinite(n) ? n : undefined;\n } catch (e) {\n return undefined;\n }\n }\n\n // Name of a field from a focus, blur or change event. Card, password and\n // hidden fields are never named, same rule as the Moxel script.\n function inputName(event) {\n try {\n var el = event.data.element;\n if (!el) return \"\";\n var type = typeof el.type === \"string\" ? el.type.toLowerCase() : \"\";\n if (type === \"password\" || type === \"hidden\") return \"\";\n var name = cleanName(el.name) || cleanName(el.id);\n if (/card|cvv|cvc|cc-|expir|security/.test(name)) return \"\";\n return name;\n } catch (e) {\n return \"\";\n }\n }\n\n // ---- sending\n\n function windowWidth() {\n try {\n var w = init.context.window.innerWidth;\n return typeof w === \"number\" && isFinite(w) ? Math.round(w) : 0;\n } catch (e) {\n return 0;\n }\n }\n\n function send(events) {\n try {\n var body = JSON.stringify({\n v: 1,\n k: SITE_KEY,\n d: DEPTH,\n now: Date.now(),\n w: windowWidth(),\n ev: events\n });\n var request = fetch(TARGET, {\n method: \"POST\",\n headers: { \"Content-Type\": \"text/plain\" },\n body: body,\n keepalive: true,\n credentials: \"omit\"\n });\n if (request && typeof request.catch === \"function\") request.catch(noop);\n } catch (e) {\n // A failed send must never reach the store.\n }\n }\n\n function base(t) {\n return { t: t, ts: Date.now(), p: PATH };\n }\n\n function custom(name, value) {\n var e = base(\"ev\");\n e.n = name;\n if (typeof value === \"number\") e.val = value;\n return e;\n }\n\n // ---- the checkout steps\n\n function onStarted() {\n var events = [base(\"pv\"), custom(\"checkout_started\")];\n if (!startedForm) {\n startedForm = true;\n var fs = base(\"fs\");\n fs.f = FORM;\n events.push(fs);\n }\n send(events);\n }\n\n function onStep(name) {\n return function () {\n send([custom(name)]);\n };\n }\n\n function onCompleted(event) {\n startedForm = false;\n send([custom(\"checkout_completed\", orderTotal(event))]);\n }\n\n // ---- errors Shopify shows next to a field\n\n function onAlert(event) {\n var alert = null;\n try {\n alert = event.data.alert;\n } catch (e) {}\n if (!alert) return;\n // Only target and type are read. message and value are never touched.\n var field = fieldFromTarget(alert.target);\n if (!field) return;\n var fe = base(\"fe\");\n fe.f = FORM;\n fe.fd = field;\n fe.k = errorKind(alert.type);\n lastField = field;\n send([fe]);\n }\n\n // ---- field events, if Shopify fires them on checkout (unverified)\n\n function onFocus(event) {\n var name = inputName(event);\n if (!name) return;\n focus = { name: name, at: Date.now(), changed: false };\n lastField = name;\n var ff = base(\"ff\");\n ff.f = FORM;\n ff.fd = name;\n send([ff]);\n }\n\n function onBlur(event) {\n var name = inputName(event);\n if (!name) return;\n var ms = 0;\n var changed = false;\n if (focus && focus.name === name) {\n ms = Math.max(0, Date.now() - focus.at);\n changed = focus.changed;\n }\n focus = null;\n lastField = name;\n var fb = base(\"fb\");\n fb.f = FORM;\n fb.fd = name;\n fb.ms = ms;\n // The value is never read. A change event means the shopper typed, so\n // that is the only reason this is ever 1.\n fb.fl = changed ? 1 : 0;\n send([fb]);\n }\n\n function onChange(event) {\n var name = inputName(event);\n if (!name) return;\n lastField = name;\n if (focus && focus.name === name) focus.changed = true;\n }\n\n // ---- wiring\n\n function listen(name, handler) {\n try {\n analytics.subscribe(name, function (event) {\n try {\n handler(event);\n } catch (e) {\n // Never let a handler error reach the store.\n }\n });\n } catch (e) {}\n }\n\n listen(\"checkout_started\", onStarted);\n listen(\"checkout_contact_info_submitted\", onStep(\"checkout_contact_info_submitted\"));\n listen(\"checkout_address_info_submitted\", onStep(\"checkout_address_info_submitted\"));\n listen(\"checkout_shipping_info_submitted\", onStep(\"checkout_shipping_info_submitted\"));\n listen(\"payment_info_submitted\", onStep(\"payment_info_submitted\"));\n listen(\"checkout_completed\", onCompleted);\n listen(\"alert_displayed\", onAlert);\n listen(\"input_focused\", onFocus);\n listen(\"input_blurred\", onBlur);\n listen(\"input_changed\", onChange);\n})();\n";
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "name": "getmoxel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Privacy-first web analytics. No cookies, no stored ids, nothing typed is ever read.",
5
+ "keywords": [
6
+ "analytics",
7
+ "web analytics",
8
+ "privacy",
9
+ "cookieless",
10
+ "form analytics",
11
+ "funnel",
12
+ "conversion",
13
+ "react",
14
+ "nextjs"
15
+ ],
16
+ "license": "MIT",
17
+ "homepage": "https://www.getmoxel.com",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/A5harib/moxel-npm.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/A5harib/moxel-npm/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "unpkg": "./dist/m.js",
29
+ "jsdelivr": "./dist/m.js",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./react": {
36
+ "types": "./dist/react.d.ts",
37
+ "default": "./dist/react.js"
38
+ },
39
+ "./node": {
40
+ "types": "./dist/node.d.ts",
41
+ "default": "./dist/node.js"
42
+ },
43
+ "./shopify": {
44
+ "types": "./dist/shopify.d.ts",
45
+ "default": "./dist/shopify.js"
46
+ },
47
+ "./package.json": "./package.json"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "PROTOCOL.md",
52
+ "README.md",
53
+ "LICENSE"
54
+ ],
55
+ "sideEffects": false,
56
+ "peerDependencies": {
57
+ "react": ">=18"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "react": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "scripts": {
65
+ "build": "node build.mjs",
66
+ "test": "node build.mjs && node test/run.mjs && node shopify/pixel.test.mjs",
67
+ "prepublishOnly": "node build.mjs"
68
+ },
69
+ "config": {
70
+ "endpoint": "https://www.getmoxel.com"
71
+ },
72
+ "devDependencies": {
73
+ "esbuild": "^0.28.2",
74
+ "playwright": "^1.64.0"
75
+ }
76
+ }