@obelism/numbers-sdk 0.2.0 → 0.4.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/README.md CHANGED
@@ -28,11 +28,18 @@ client on `window.numbers`:
28
28
  numbers.track('sign_up', 'footer_form')
29
29
  ```
30
30
 
31
+ A page view is counted once per pathname: a change that only touches the
32
+ query string or hash (`?tab=2`, `#pricing`) isn't a second page view.
33
+
31
34
  `data-space` names the space (one site or project of the organization) the
32
35
  events belong to; without it they go to the organization's first space.
33
36
  `data-endpoint="https://…"` sends somewhere else, e.g. a proxy on your own
34
- domain. npm CDNs serve the same file from
35
- `@obelism/numbers-sdk/dist/script.global.js`.
37
+ domain; without it events go to numbers' origin, even when a CDN serves the
38
+ file. `data-debug` turns on the development warnings (snake_case names, an 8 KB
39
+ body, a `data-organization` or `data-space` it can't use, an unknown
40
+ `data-environment` or `data-storage`), and
41
+ `data-storage="session"` (or `"none"`) counts without a cookie. npm CDNs
42
+ serve the same file from `@obelism/numbers-sdk/dist/script.global.js`.
36
43
 
37
44
  ## Module
38
45
 
@@ -52,7 +59,8 @@ const numbers = createNumbers({
52
59
  })
53
60
 
54
61
  // Page views now and on every navigation, and clicks on tagged elements.
55
- const stop = numbers.autoTrack() // { pageViews?: boolean, clicks?: boolean }
62
+ const stop = numbers.autoTrack()
63
+ // { pageViews?, clicks?, outbound?, downloads?, forms? }
56
64
 
57
65
  numbers.track('add_to_cart', 'product_page', {
58
66
  value: 59.95,
@@ -60,9 +68,16 @@ numbers.track('add_to_cart', 'product_page', {
60
68
  params: { ecommerce: { items: [{ item_id: 'sku_1' }] } },
61
69
  })
62
70
  numbers.track('view_item', 'list', { dedupeKey: 'sku_1' }) // once per key
71
+ numbers.formSubmit(form, 'newsletter') // or autoTrack({ forms: true })
63
72
  numbers.pageView() // by hand, without autoTrack
64
73
  ```
65
74
 
75
+ `autoTrack({ outbound: true })` sends `outbound_click` for links to another
76
+ site, `downloads: true` sends `file_download` for links to a file, and
77
+ `forms: true` sends `form_submit` for each submitted form. Calling
78
+ `autoTrack` again replaces the previous listeners, so it doesn't double
79
+ count.
80
+
66
81
  The client is safe to create during server rendering: outside the browser
67
82
  every call does nothing and returns `null`.
68
83
 
@@ -93,12 +108,20 @@ its visual editor shows each block's click rate.
93
108
 
94
109
  ### Visitors
95
110
 
96
- A visitor is a random id in the `visitorId` cookie (`visi_` and 26
111
+ A visitor is a random id in the `obelism_visitor_id` cookie (`visi_` and 26
97
112
  characters, 30 days from the last visit), the same cookie the improve SDK
98
- uses. `numbers.visitor()` returns `{ id, recurring }`. The device categories
113
+ uses. The old `visitorId` cookie is read and written too, for a deprecation
114
+ window, so a site that still runs an older improve SDK keeps one visitor.
115
+ `numbers.visitor()` returns `{ id, recurring }`. The device categories
99
116
  (pointer, device, browser, os, screen size) come from the user agent; pass
100
117
  `device` to `createNumbers` (or call `setDevice`) to use your own.
101
118
 
119
+ For a site that counts without asking for consent, `storage: 'session'`
120
+ keeps the id in sessionStorage for the tab and `storage: 'none'` makes a new
121
+ one on every client (so every full load in a page that isn't an SPA): neither
122
+ sets a cookie. The script takes the same as
123
+ `data-storage`.
124
+
102
125
  ### Limits
103
126
 
104
127
  Strings are capped to what numbers accepts (256 characters, currency 8),
package/dist/index.d.mts CHANGED
@@ -1,3 +1,15 @@
1
+ type Visitor = {
2
+ id: string;
3
+ /** Whether the visitor had an id already: `recurring` on the server. */
4
+ recurring: boolean;
5
+ };
6
+ /**
7
+ * Where the visitor id lives. `cookie` is the shared 30-day cookie; `session`
8
+ * keeps it in sessionStorage for the tab only (no cookie); `none` makes a new
9
+ * id for every page load, so visits still count but unique visitors don't.
10
+ */
11
+ type VisitorStorage = 'cookie' | 'session' | 'none';
12
+
1
13
  type Environment = 'develop' | 'staging' | 'production';
2
14
  /**
3
15
  * The visitor's device categories, as improve's audiences know them. The
@@ -28,6 +40,13 @@ type NumbersOptions = {
28
40
  device?: Device;
29
41
  /** Silence the development warnings (e.g. a name that isn't snake_case). */
30
42
  disableWarnings?: boolean;
43
+ /**
44
+ * Where the visitor id lives. Default `cookie`: the shared 30-day
45
+ * `obelism_visitor_id` cookie. `session` keeps it in sessionStorage for the
46
+ * tab only, and `none` makes a new id per page load: neither sets a cookie,
47
+ * for a site that counts visits without asking for consent.
48
+ */
49
+ storage?: VisitorStorage;
31
50
  };
32
51
  /**
33
52
  * Event names GA4 / Google Tag Manager recommend. Reusing them lines your
@@ -92,12 +111,6 @@ type EventBeacon = BeaconCommon<'event'> & PageContext & {
92
111
  };
93
112
  type ExposureBeacon = BeaconCommon<'exposure'> & Exposure;
94
113
 
95
- type Visitor = {
96
- id: string;
97
- /** Whether the visitor had a cookie already: `recurring` on the server. */
98
- recurring: boolean;
99
- };
100
-
101
114
  type AutoTrackOptions = {
102
115
  /** A page view now and on every client-side navigation. Default on. */
103
116
  pageViews?: boolean;
@@ -106,6 +119,21 @@ type AutoTrackOptions = {
106
119
  * element with `data-numbers-id="…"`. Default on.
107
120
  */
108
121
  clicks?: boolean;
122
+ /**
123
+ * An `outbound_click` for clicks on links to another site, scoped to the
124
+ * destination (or the link's `data-numbers-id`). Default off.
125
+ */
126
+ outbound?: boolean;
127
+ /**
128
+ * A `file_download` for clicks on links to a file, scoped to its name (or
129
+ * the link's `data-numbers-id`). Default off.
130
+ */
131
+ downloads?: boolean;
132
+ /**
133
+ * A `form_submit` for every submitted form, scoped to its
134
+ * `data-numbers-id`, its `id`, or its action. Default off.
135
+ */
136
+ forms?: boolean;
109
137
  };
110
138
  /** What a send resolves to: the response, or nothing when it failed. */
111
139
  type Sent = Promise<Response | undefined>;
@@ -126,17 +154,21 @@ type NumbersClient = {
126
154
  track: (event: EventName, scope?: string, options?: TrackOptions) => Sent | null;
127
155
  /** Sends a page view of the current page. */
128
156
  pageView: () => Sent | null;
157
+ /** Sends a `form_submit` for a form, scoped to its id or action. */
158
+ formSubmit: (form: HTMLFormElement, scope?: string) => Sent | null;
129
159
  /** Sends a test's or flag's assignment, once per subject per client. */
130
160
  expose: (exposure: Exposure) => Sent | null;
131
- /** Starts page views and click tracking; returns a function that stops them. */
161
+ /** Starts automatic tracking; returns a function that stops it. */
132
162
  autoTrack: (options?: AutoTrackOptions) => () => void;
133
163
  };
134
- declare const createNumbers: ({ organizationId, spaceId, environment, endpoint, device: givenDevice, disableWarnings, }: NumbersOptions) => NumbersClient;
164
+ declare const createNumbers: ({ organizationId, spaceId, environment, endpoint, device: givenDevice, disableWarnings, storage, }: NumbersOptions) => NumbersClient;
135
165
 
136
166
  declare const DEFAULT_ENDPOINT = "https://numbers.obelism.studio/api/log";
167
+ declare const VISITOR_COOKIE = "obelism_visitor_id";
168
+ declare const LEGACY_VISITOR_COOKIE = "visitorId";
137
169
 
138
170
  declare const detectDevice: (userAgent: string) => Device;
139
171
 
140
- export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
141
- export type { AutoTrackOptions, BeaconCommon, Device, Environment, EventBeacon, EventName, Exposure, ExposureBeacon, NumbersClient, NumbersOptions, PageContext, RecommendedEventName, Sent, TrackOptions, Visitor };
172
+ export { DEFAULT_ENDPOINT, LEGACY_VISITOR_COOKIE, VISITOR_COOKIE, createNumbers, detectDevice };
173
+ export type { AutoTrackOptions, BeaconCommon, Device, Environment, EventBeacon, EventName, Exposure, ExposureBeacon, NumbersClient, NumbersOptions, PageContext, RecommendedEventName, Sent, TrackOptions, Visitor, VisitorStorage };
142
174
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sources":["../src/types.ts","../src/visitor.ts","../src/client.ts","../src/constants.ts","../src/device.ts"],"mappings":"AAAA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA,KAAK,MAAM;AACX;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kBAAkB,WAAW;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,oBAAoB;AACzB;AACA,KAAK,SAAS,GAAG,oBAAoB;AACrC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,QAAQ;AACb;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW,GAAG,YAAY,YAAY,WAAW;AACtD;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA,KAAK,cAAc,GAAG,YAAY,eAAe,QAAQ;;AC5FzD,KAAK,OAAO;AACZ;AACA;AACA;AACA;;ACFA,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,IAAI,GAAG,OAAO,CAAC,QAAQ;AAC5B,KAAK,aAAa;AAClB;AACA;AACA,0BAA0B,WAAW;AACrC;AACA,mBAAmB,OAAO;AAC1B;AACA,wBAAwB,MAAM;AAC9B;AACA;AACA;AACA;AACA;AACA;AACA,mBAAmB,SAAS,4BAA4B,YAAY,KAAK,IAAI;AAC7E;AACA,oBAAoB,IAAI;AACxB;AACA,uBAAuB,QAAQ,KAAK,IAAI;AACxC;AACA,0BAA0B,gBAAgB;AAC1C;AACA,cAAc,aAAa,8FAA8F,cAAc,KAAK,aAAa;;ACnCzJ,cAAc,gBAAgB;;ACC9B,cAAc,YAAY,yBAAyB,MAAM;;","names":[]}
1
+ {"version":3,"file":"index.d.mts","sources":["../src/visitor.ts","../src/types.ts","../src/client.ts","../src/constants.ts","../src/device.ts"],"mappings":"AAAA,KAAK,OAAO;AACZ;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;;ACTnB,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA,KAAK,MAAM;AACX;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kBAAkB,WAAW;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAc,cAAc;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,oBAAoB;AACzB;AACA,KAAK,SAAS,GAAG,oBAAoB;AACrC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,QAAQ;AACb;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW,GAAG,YAAY,YAAY,WAAW;AACtD;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA,KAAK,cAAc,GAAG,YAAY,eAAe,QAAQ;;AClGzD,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,IAAI,GAAG,OAAO,CAAC,QAAQ;AAC5B,KAAK,aAAa;AAClB;AACA;AACA,0BAA0B,WAAW;AACrC;AACA,mBAAmB,OAAO;AAC1B;AACA,wBAAwB,MAAM;AAC9B;AACA;AACA;AACA;AACA;AACA;AACA,mBAAmB,SAAS,4BAA4B,YAAY,KAAK,IAAI;AAC7E;AACA,oBAAoB,IAAI;AACxB;AACA,uBAAuB,eAAe,qBAAqB,IAAI;AAC/D;AACA,uBAAuB,QAAQ,KAAK,IAAI;AACxC;AACA,0BAA0B,gBAAgB;AAC1C;AACA,cAAc,aAAa,uGAAuG,cAAc,KAAK,aAAa;;ACpDlK,cAAc,gBAAgB;AAC9B,cAAc,cAAc;AAC5B,cAAc,qBAAqB;;ACDnC,cAAc,YAAY,yBAAyB,MAAM;;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,15 @@
1
+ type Visitor = {
2
+ id: string;
3
+ /** Whether the visitor had an id already: `recurring` on the server. */
4
+ recurring: boolean;
5
+ };
6
+ /**
7
+ * Where the visitor id lives. `cookie` is the shared 30-day cookie; `session`
8
+ * keeps it in sessionStorage for the tab only (no cookie); `none` makes a new
9
+ * id for every page load, so visits still count but unique visitors don't.
10
+ */
11
+ type VisitorStorage = 'cookie' | 'session' | 'none';
12
+
1
13
  type Environment = 'develop' | 'staging' | 'production';
2
14
  /**
3
15
  * The visitor's device categories, as improve's audiences know them. The
@@ -28,6 +40,13 @@ type NumbersOptions = {
28
40
  device?: Device;
29
41
  /** Silence the development warnings (e.g. a name that isn't snake_case). */
30
42
  disableWarnings?: boolean;
43
+ /**
44
+ * Where the visitor id lives. Default `cookie`: the shared 30-day
45
+ * `obelism_visitor_id` cookie. `session` keeps it in sessionStorage for the
46
+ * tab only, and `none` makes a new id per page load: neither sets a cookie,
47
+ * for a site that counts visits without asking for consent.
48
+ */
49
+ storage?: VisitorStorage;
31
50
  };
32
51
  /**
33
52
  * Event names GA4 / Google Tag Manager recommend. Reusing them lines your
@@ -92,12 +111,6 @@ type EventBeacon = BeaconCommon<'event'> & PageContext & {
92
111
  };
93
112
  type ExposureBeacon = BeaconCommon<'exposure'> & Exposure;
94
113
 
95
- type Visitor = {
96
- id: string;
97
- /** Whether the visitor had a cookie already: `recurring` on the server. */
98
- recurring: boolean;
99
- };
100
-
101
114
  type AutoTrackOptions = {
102
115
  /** A page view now and on every client-side navigation. Default on. */
103
116
  pageViews?: boolean;
@@ -106,6 +119,21 @@ type AutoTrackOptions = {
106
119
  * element with `data-numbers-id="…"`. Default on.
107
120
  */
108
121
  clicks?: boolean;
122
+ /**
123
+ * An `outbound_click` for clicks on links to another site, scoped to the
124
+ * destination (or the link's `data-numbers-id`). Default off.
125
+ */
126
+ outbound?: boolean;
127
+ /**
128
+ * A `file_download` for clicks on links to a file, scoped to its name (or
129
+ * the link's `data-numbers-id`). Default off.
130
+ */
131
+ downloads?: boolean;
132
+ /**
133
+ * A `form_submit` for every submitted form, scoped to its
134
+ * `data-numbers-id`, its `id`, or its action. Default off.
135
+ */
136
+ forms?: boolean;
109
137
  };
110
138
  /** What a send resolves to: the response, or nothing when it failed. */
111
139
  type Sent = Promise<Response | undefined>;
@@ -126,17 +154,21 @@ type NumbersClient = {
126
154
  track: (event: EventName, scope?: string, options?: TrackOptions) => Sent | null;
127
155
  /** Sends a page view of the current page. */
128
156
  pageView: () => Sent | null;
157
+ /** Sends a `form_submit` for a form, scoped to its id or action. */
158
+ formSubmit: (form: HTMLFormElement, scope?: string) => Sent | null;
129
159
  /** Sends a test's or flag's assignment, once per subject per client. */
130
160
  expose: (exposure: Exposure) => Sent | null;
131
- /** Starts page views and click tracking; returns a function that stops them. */
161
+ /** Starts automatic tracking; returns a function that stops it. */
132
162
  autoTrack: (options?: AutoTrackOptions) => () => void;
133
163
  };
134
- declare const createNumbers: ({ organizationId, spaceId, environment, endpoint, device: givenDevice, disableWarnings, }: NumbersOptions) => NumbersClient;
164
+ declare const createNumbers: ({ organizationId, spaceId, environment, endpoint, device: givenDevice, disableWarnings, storage, }: NumbersOptions) => NumbersClient;
135
165
 
136
166
  declare const DEFAULT_ENDPOINT = "https://numbers.obelism.studio/api/log";
167
+ declare const VISITOR_COOKIE = "obelism_visitor_id";
168
+ declare const LEGACY_VISITOR_COOKIE = "visitorId";
137
169
 
138
170
  declare const detectDevice: (userAgent: string) => Device;
139
171
 
140
- export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
141
- export type { AutoTrackOptions, BeaconCommon, Device, Environment, EventBeacon, EventName, Exposure, ExposureBeacon, NumbersClient, NumbersOptions, PageContext, RecommendedEventName, Sent, TrackOptions, Visitor };
172
+ export { DEFAULT_ENDPOINT, LEGACY_VISITOR_COOKIE, VISITOR_COOKIE, createNumbers, detectDevice };
173
+ export type { AutoTrackOptions, BeaconCommon, Device, Environment, EventBeacon, EventName, Exposure, ExposureBeacon, NumbersClient, NumbersOptions, PageContext, RecommendedEventName, Sent, TrackOptions, Visitor, VisitorStorage };
142
174
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sources":["../src/types.ts","../src/visitor.ts","../src/client.ts","../src/constants.ts","../src/device.ts"],"mappings":"AAAA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA,KAAK,MAAM;AACX;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kBAAkB,WAAW;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,oBAAoB;AACzB;AACA,KAAK,SAAS,GAAG,oBAAoB;AACrC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,QAAQ;AACb;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW,GAAG,YAAY,YAAY,WAAW;AACtD;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA,KAAK,cAAc,GAAG,YAAY,eAAe,QAAQ;;AC5FzD,KAAK,OAAO;AACZ;AACA;AACA;AACA;;ACFA,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,IAAI,GAAG,OAAO,CAAC,QAAQ;AAC5B,KAAK,aAAa;AAClB;AACA;AACA,0BAA0B,WAAW;AACrC;AACA,mBAAmB,OAAO;AAC1B;AACA,wBAAwB,MAAM;AAC9B;AACA;AACA;AACA;AACA;AACA;AACA,mBAAmB,SAAS,4BAA4B,YAAY,KAAK,IAAI;AAC7E;AACA,oBAAoB,IAAI;AACxB;AACA,uBAAuB,QAAQ,KAAK,IAAI;AACxC;AACA,0BAA0B,gBAAgB;AAC1C;AACA,cAAc,aAAa,8FAA8F,cAAc,KAAK,aAAa;;ACnCzJ,cAAc,gBAAgB;;ACC9B,cAAc,YAAY,yBAAyB,MAAM;;","names":[]}
1
+ {"version":3,"file":"index.d.ts","sources":["../src/visitor.ts","../src/types.ts","../src/client.ts","../src/constants.ts","../src/device.ts"],"mappings":"AAAA,KAAK,OAAO;AACZ;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;;ACTnB,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA,KAAK,MAAM;AACX;AACA;AACA;AACA;AACA;AACA,KAAK,cAAc;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kBAAkB,WAAW;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAc,cAAc;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,oBAAoB;AACzB;AACA,KAAK,SAAS,GAAG,oBAAoB;AACrC,KAAK,YAAY;AACjB;AACA;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,QAAQ;AACb;AACA;AACA;AACA;AACA;AACA,KAAK,YAAY;AACjB;AACA;AACA;AACA,iBAAiB,WAAW;AAC5B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW;AAChB;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,WAAW,GAAG,YAAY,YAAY,WAAW;AACtD;AACA;AACA;AACA;AACA,aAAa,MAAM;AACnB;AACA,KAAK,cAAc,GAAG,YAAY,eAAe,QAAQ;;AClGzD,KAAK,gBAAgB;AACrB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,KAAK,IAAI,GAAG,OAAO,CAAC,QAAQ;AAC5B,KAAK,aAAa;AAClB;AACA;AACA,0BAA0B,WAAW;AACrC;AACA,mBAAmB,OAAO;AAC1B;AACA,wBAAwB,MAAM;AAC9B;AACA;AACA;AACA;AACA;AACA;AACA,mBAAmB,SAAS,4BAA4B,YAAY,KAAK,IAAI;AAC7E;AACA,oBAAoB,IAAI;AACxB;AACA,uBAAuB,eAAe,qBAAqB,IAAI;AAC/D;AACA,uBAAuB,QAAQ,KAAK,IAAI;AACxC;AACA,0BAA0B,gBAAgB;AAC1C;AACA,cAAc,aAAa,uGAAuG,cAAc,KAAK,aAAa;;ACpDlK,cAAc,gBAAgB;AAC9B,cAAc,cAAc;AAC5B,cAAc,qBAAqB;;ACDnC,cAAc,YAAY,yBAAyB,MAAM;;","names":[]}
package/dist/index.js CHANGED
@@ -2,12 +2,17 @@ Object.defineProperty(exports, '__esModule', { value: true });
2
2
 
3
3
  const DEFAULT_ENDPOINT = 'https://numbers.obelism.studio/api/log';
4
4
  // The visitor cookie and id format, shared with the improve SDK so a visitor
5
- // is the same one in both: `visi_` and 26 characters.
6
- const VISITOR_COOKIE = 'visitorId';
5
+ // is the same one in both: `visi_` and 26 characters. The name is namespaced
6
+ // so a consent declaration can name it without colliding with other analytics;
7
+ // `visitorId` is the old name, still read so existing visitors keep their id.
8
+ const VISITOR_COOKIE = 'obelism_visitor_id';
9
+ const LEGACY_VISITOR_COOKIE = 'visitorId';
7
10
  const VISITOR_ID_PREFIX = 'visi';
8
11
  const VISITOR_ID_SEPARATOR = '_';
9
12
  const VISITOR_ID_LENGTH = 26;
10
13
  const VISITOR_COOKIE_DAYS = 30;
14
+ // The sessionStorage key for the session-only visitor (no cookie).
15
+ const VISITOR_SESSION_KEY = 'obelism_visitor_id';
11
16
  // numbers' limits (`POST /api/log`): a longer string is a 400 and a bigger
12
17
  // body a 413, and either loses the event, so the client caps what it can.
13
18
  const MAX_FIELD_LENGTH = 256;
@@ -90,19 +95,73 @@ const createVisitorId = ()=>{
90
95
  for (const byte of random)id += CHARACTERS[byte % CHARACTERS.length];
91
96
  return `${VISITOR_ID_PREFIX}${VISITOR_ID_SEPARATOR}${id}`;
92
97
  };
93
- // The visitor in the cookie, or a new one; either way the cookie is set
94
- // again, so it lasts 30 days from the last visit.
95
- const loadVisitor = ()=>{
96
- const existing = readCookie(VISITOR_COOKIE);
98
+ const readStoredVisitor = (storage)=>{
99
+ if (storage === 'session') {
100
+ try {
101
+ return sessionStorage.getItem(VISITOR_SESSION_KEY) ?? '';
102
+ } catch {
103
+ return '';
104
+ }
105
+ }
106
+ return readCookie(VISITOR_COOKIE) || readCookie(LEGACY_VISITOR_COOKIE);
107
+ };
108
+ const writeStoredVisitor = (storage, id)=>{
109
+ if (storage === 'session') {
110
+ try {
111
+ sessionStorage.setItem(VISITOR_SESSION_KEY, id);
112
+ } catch {
113
+ // Storage can be disabled; the id then lives for this call only.
114
+ }
115
+ return;
116
+ }
117
+ writeCookie(VISITOR_COOKIE, id);
118
+ // Write the old cookie too, for a deprecation window: a site that still
119
+ // runs an older improve SDK reads only `visitorId` and would mint a second
120
+ // id for the same person. Drop this with the legacy read.
121
+ writeCookie(LEGACY_VISITOR_COOKIE, id);
122
+ };
123
+ // The visitor in its storage, or a new one; either way it is written again, so
124
+ // the cookie lasts 30 days from the last visit and the session id lasts the tab.
125
+ const loadVisitor = (storage = 'cookie')=>{
126
+ if (storage === 'none') return {
127
+ id: createVisitorId(),
128
+ recurring: false
129
+ };
130
+ const existing = readStoredVisitor(storage);
97
131
  const recurring = isVisitorId(existing);
98
132
  const id = recurring ? existing : createVisitorId();
99
- writeCookie(VISITOR_COOKIE, id);
133
+ writeStoredVisitor(storage, id);
100
134
  return {
101
135
  id,
102
136
  recurring
103
137
  };
104
138
  };
105
139
 
140
+ // Documents, archives and media, not images a browser shows inline (a link
141
+ // with `download` still counts, whatever it points at).
142
+ const DOWNLOAD_EXTENSIONS = [
143
+ '7z',
144
+ 'avi',
145
+ 'csv',
146
+ 'doc',
147
+ 'docx',
148
+ 'gz',
149
+ 'mov',
150
+ 'mp3',
151
+ 'mp4',
152
+ 'pdf',
153
+ 'ppt',
154
+ 'pptx',
155
+ 'rar',
156
+ 'tar',
157
+ 'txt',
158
+ 'wav',
159
+ 'xls',
160
+ 'xlsx',
161
+ 'xml',
162
+ 'zip'
163
+ ];
164
+ const isDownload = (url, link)=>link.hasAttribute('download') || DOWNLOAD_EXTENSIONS.includes(url.pathname.split('.').pop()?.toLowerCase() ?? '');
106
165
  const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
107
166
  const parseRetryAfterMs = (header)=>{
108
167
  if (!header) return undefined;
@@ -112,13 +171,17 @@ const parseRetryAfterMs = (header)=>{
112
171
  return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
113
172
  };
114
173
  const isBrowser = ()=>typeof window !== 'undefined' && typeof document !== 'undefined';
115
- const createNumbers = ({ organizationId, spaceId, environment = 'production', endpoint = DEFAULT_ENDPOINT, device: givenDevice, disableWarnings = false })=>{
174
+ const createNumbers = ({ organizationId, spaceId, environment = 'production', endpoint = DEFAULT_ENDPOINT, device: givenDevice, disableWarnings = false, storage = 'cookie' })=>{
116
175
  let visitor = null;
117
176
  let device = givenDevice;
118
177
  let url = endpoint;
119
178
  // Epoch ms until which sending is paused after a 429.
120
179
  let pausedUntil = 0;
121
180
  let firstPageView = true;
181
+ // The path of the last page view, shared by every autoTrack: a second
182
+ // call replaces the listeners but must not count the page again.
183
+ let lastPath = null;
184
+ let stopAutoTrack = null;
122
185
  const sentKeys = new Set();
123
186
  const exposed = new Set();
124
187
  const warned = new Set();
@@ -129,7 +192,7 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
129
192
  };
130
193
  const getVisitor = ()=>{
131
194
  if (!isBrowser()) return null;
132
- visitor ??= loadVisitor();
195
+ visitor ??= loadVisitor(storage);
133
196
  return visitor;
134
197
  };
135
198
  const common = (type, current)=>{
@@ -156,8 +219,10 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
156
219
  if (typeof TextEncoder !== 'undefined' && new TextEncoder().encode(serialized).length > MAX_BODY_BYTES) warn(`size:${label}`, `"${label}" is over ${MAX_BODY_BYTES} bytes and will be refused: trim its \`params\`.`);
157
220
  return fetch(url, {
158
221
  method: 'POST',
222
+ // A CORS-safelisted type, so a browser sends the event without a
223
+ // preflight; numbers parses the body as JSON either way.
159
224
  headers: {
160
- 'Content-Type': 'application/json'
225
+ 'Content-Type': 'text/plain;charset=UTF-8'
161
226
  },
162
227
  body: serialized,
163
228
  keepalive: true
@@ -203,6 +268,17 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
203
268
  firstPageView = false;
204
269
  return sendEvent('page_view', location.pathname, {}, referrer);
205
270
  };
271
+ const formScope = (form)=>{
272
+ const given = form.getAttribute('data-numbers-id') || form.id;
273
+ if (given) return given;
274
+ try {
275
+ const action = new URL(form.action, location.href);
276
+ return action.origin === location.origin ? action.pathname : `${action.origin}${action.pathname}`;
277
+ } catch {
278
+ return 'form';
279
+ }
280
+ };
281
+ const formSubmit = (form, scope)=>track('form_submit', scope || formScope(form));
206
282
  const expose = (exposure)=>{
207
283
  const current = getVisitor();
208
284
  if (!current || paused()) return null;
@@ -214,13 +290,15 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
214
290
  ...exposure
215
291
  }, key);
216
292
  };
217
- const autoTrack = ({ pageViews = true, clicks = true } = {})=>{
293
+ const autoTrack = ({ pageViews = true, clicks = true, outbound = false, downloads = false, forms = false } = {})=>{
218
294
  if (!isBrowser()) return ()=>{};
295
+ // A second call replaces the first instead of stacking listeners, so a
296
+ // site can add outbound/download/form tracking to the script's autoTrack.
297
+ stopAutoTrack?.();
219
298
  const stops = [];
220
299
  if (pageViews) {
221
300
  // One page view per path: replaceState for a query or hash change
222
301
  // isn't a new page.
223
- let lastPath = null;
224
302
  const onNavigate = ()=>{
225
303
  if (location.pathname === lastPath) return;
226
304
  lastPath = location.pathname;
@@ -255,9 +333,54 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
255
333
  document.addEventListener('click', onClick, true);
256
334
  stops.push(()=>document.removeEventListener('click', onClick, true));
257
335
  }
258
- return ()=>{
259
- for (const stop of stops)stop();
336
+ if (outbound || downloads) {
337
+ const onLinkClick = (event)=>{
338
+ const target = event.target;
339
+ const link = target?.closest?.('a[href]');
340
+ if (!link) return;
341
+ let url;
342
+ try {
343
+ url = new URL(link.href, location.href);
344
+ } catch {
345
+ return;
346
+ }
347
+ // Only http(s): a data: URL's path is its whole payload, and
348
+ // mailto:/tel: aren't destinations.
349
+ if (!/^https?:$/.test(url.protocol)) return;
350
+ const id = link.closest('[data-numbers-id]')?.getAttribute('data-numbers-id');
351
+ const fileName = url.pathname.split('/').pop() || url.pathname;
352
+ const extension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
353
+ if (downloads && isDownload(url, link)) track('file_download', id ?? fileName, id ? undefined : {
354
+ params: {
355
+ file_name: extension ? fileName.slice(0, -(extension.length + 1)) : fileName,
356
+ ...extension && {
357
+ file_extension: extension
358
+ }
359
+ }
360
+ });
361
+ else if (outbound && url.origin !== location.origin) track('outbound_click', id ?? `${url.origin}${url.pathname}`, id ? undefined : {
362
+ params: {
363
+ link_domain: url.hostname
364
+ }
365
+ });
366
+ };
367
+ document.addEventListener('click', onLinkClick, true);
368
+ stops.push(()=>document.removeEventListener('click', onLinkClick, true));
369
+ }
370
+ if (forms) {
371
+ const onSubmit = (event)=>{
372
+ const form = event.target;
373
+ if (form instanceof HTMLFormElement) formSubmit(form);
374
+ };
375
+ document.addEventListener('submit', onSubmit, true);
376
+ stops.push(()=>document.removeEventListener('submit', onSubmit, true));
377
+ }
378
+ const stop = ()=>{
379
+ for (const fn of stops)fn();
380
+ if (stopAutoTrack === stop) stopAutoTrack = null;
260
381
  };
382
+ stopAutoTrack = stop;
383
+ return stop;
261
384
  };
262
385
  return {
263
386
  organizationId,
@@ -272,11 +395,14 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
272
395
  },
273
396
  track,
274
397
  pageView,
398
+ formSubmit,
275
399
  expose,
276
400
  autoTrack
277
401
  };
278
402
  };
279
403
 
280
404
  exports.DEFAULT_ENDPOINT = DEFAULT_ENDPOINT;
405
+ exports.LEGACY_VISITOR_COOKIE = LEGACY_VISITOR_COOKIE;
406
+ exports.VISITOR_COOKIE = VISITOR_COOKIE;
281
407
  exports.createNumbers = createNumbers;
282
408
  exports.detectDevice = detectDevice;
package/dist/index.mjs CHANGED
@@ -1,11 +1,16 @@
1
1
  const DEFAULT_ENDPOINT = 'https://numbers.obelism.studio/api/log';
2
2
  // The visitor cookie and id format, shared with the improve SDK so a visitor
3
- // is the same one in both: `visi_` and 26 characters.
4
- const VISITOR_COOKIE = 'visitorId';
3
+ // is the same one in both: `visi_` and 26 characters. The name is namespaced
4
+ // so a consent declaration can name it without colliding with other analytics;
5
+ // `visitorId` is the old name, still read so existing visitors keep their id.
6
+ const VISITOR_COOKIE = 'obelism_visitor_id';
7
+ const LEGACY_VISITOR_COOKIE = 'visitorId';
5
8
  const VISITOR_ID_PREFIX = 'visi';
6
9
  const VISITOR_ID_SEPARATOR = '_';
7
10
  const VISITOR_ID_LENGTH = 26;
8
11
  const VISITOR_COOKIE_DAYS = 30;
12
+ // The sessionStorage key for the session-only visitor (no cookie).
13
+ const VISITOR_SESSION_KEY = 'obelism_visitor_id';
9
14
  // numbers' limits (`POST /api/log`): a longer string is a 400 and a bigger
10
15
  // body a 413, and either loses the event, so the client caps what it can.
11
16
  const MAX_FIELD_LENGTH = 256;
@@ -88,19 +93,73 @@ const createVisitorId = ()=>{
88
93
  for (const byte of random)id += CHARACTERS[byte % CHARACTERS.length];
89
94
  return `${VISITOR_ID_PREFIX}${VISITOR_ID_SEPARATOR}${id}`;
90
95
  };
91
- // The visitor in the cookie, or a new one; either way the cookie is set
92
- // again, so it lasts 30 days from the last visit.
93
- const loadVisitor = ()=>{
94
- const existing = readCookie(VISITOR_COOKIE);
96
+ const readStoredVisitor = (storage)=>{
97
+ if (storage === 'session') {
98
+ try {
99
+ return sessionStorage.getItem(VISITOR_SESSION_KEY) ?? '';
100
+ } catch {
101
+ return '';
102
+ }
103
+ }
104
+ return readCookie(VISITOR_COOKIE) || readCookie(LEGACY_VISITOR_COOKIE);
105
+ };
106
+ const writeStoredVisitor = (storage, id)=>{
107
+ if (storage === 'session') {
108
+ try {
109
+ sessionStorage.setItem(VISITOR_SESSION_KEY, id);
110
+ } catch {
111
+ // Storage can be disabled; the id then lives for this call only.
112
+ }
113
+ return;
114
+ }
115
+ writeCookie(VISITOR_COOKIE, id);
116
+ // Write the old cookie too, for a deprecation window: a site that still
117
+ // runs an older improve SDK reads only `visitorId` and would mint a second
118
+ // id for the same person. Drop this with the legacy read.
119
+ writeCookie(LEGACY_VISITOR_COOKIE, id);
120
+ };
121
+ // The visitor in its storage, or a new one; either way it is written again, so
122
+ // the cookie lasts 30 days from the last visit and the session id lasts the tab.
123
+ const loadVisitor = (storage = 'cookie')=>{
124
+ if (storage === 'none') return {
125
+ id: createVisitorId(),
126
+ recurring: false
127
+ };
128
+ const existing = readStoredVisitor(storage);
95
129
  const recurring = isVisitorId(existing);
96
130
  const id = recurring ? existing : createVisitorId();
97
- writeCookie(VISITOR_COOKIE, id);
131
+ writeStoredVisitor(storage, id);
98
132
  return {
99
133
  id,
100
134
  recurring
101
135
  };
102
136
  };
103
137
 
138
+ // Documents, archives and media, not images a browser shows inline (a link
139
+ // with `download` still counts, whatever it points at).
140
+ const DOWNLOAD_EXTENSIONS = [
141
+ '7z',
142
+ 'avi',
143
+ 'csv',
144
+ 'doc',
145
+ 'docx',
146
+ 'gz',
147
+ 'mov',
148
+ 'mp3',
149
+ 'mp4',
150
+ 'pdf',
151
+ 'ppt',
152
+ 'pptx',
153
+ 'rar',
154
+ 'tar',
155
+ 'txt',
156
+ 'wav',
157
+ 'xls',
158
+ 'xlsx',
159
+ 'xml',
160
+ 'zip'
161
+ ];
162
+ const isDownload = (url, link)=>link.hasAttribute('download') || DOWNLOAD_EXTENSIONS.includes(url.pathname.split('.').pop()?.toLowerCase() ?? '');
104
163
  const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
105
164
  const parseRetryAfterMs = (header)=>{
106
165
  if (!header) return undefined;
@@ -110,13 +169,17 @@ const parseRetryAfterMs = (header)=>{
110
169
  return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
111
170
  };
112
171
  const isBrowser = ()=>typeof window !== 'undefined' && typeof document !== 'undefined';
113
- const createNumbers = ({ organizationId, spaceId, environment = 'production', endpoint = DEFAULT_ENDPOINT, device: givenDevice, disableWarnings = false })=>{
172
+ const createNumbers = ({ organizationId, spaceId, environment = 'production', endpoint = DEFAULT_ENDPOINT, device: givenDevice, disableWarnings = false, storage = 'cookie' })=>{
114
173
  let visitor = null;
115
174
  let device = givenDevice;
116
175
  let url = endpoint;
117
176
  // Epoch ms until which sending is paused after a 429.
118
177
  let pausedUntil = 0;
119
178
  let firstPageView = true;
179
+ // The path of the last page view, shared by every autoTrack: a second
180
+ // call replaces the listeners but must not count the page again.
181
+ let lastPath = null;
182
+ let stopAutoTrack = null;
120
183
  const sentKeys = new Set();
121
184
  const exposed = new Set();
122
185
  const warned = new Set();
@@ -127,7 +190,7 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
127
190
  };
128
191
  const getVisitor = ()=>{
129
192
  if (!isBrowser()) return null;
130
- visitor ??= loadVisitor();
193
+ visitor ??= loadVisitor(storage);
131
194
  return visitor;
132
195
  };
133
196
  const common = (type, current)=>{
@@ -154,8 +217,10 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
154
217
  if (typeof TextEncoder !== 'undefined' && new TextEncoder().encode(serialized).length > MAX_BODY_BYTES) warn(`size:${label}`, `"${label}" is over ${MAX_BODY_BYTES} bytes and will be refused: trim its \`params\`.`);
155
218
  return fetch(url, {
156
219
  method: 'POST',
220
+ // A CORS-safelisted type, so a browser sends the event without a
221
+ // preflight; numbers parses the body as JSON either way.
157
222
  headers: {
158
- 'Content-Type': 'application/json'
223
+ 'Content-Type': 'text/plain;charset=UTF-8'
159
224
  },
160
225
  body: serialized,
161
226
  keepalive: true
@@ -201,6 +266,17 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
201
266
  firstPageView = false;
202
267
  return sendEvent('page_view', location.pathname, {}, referrer);
203
268
  };
269
+ const formScope = (form)=>{
270
+ const given = form.getAttribute('data-numbers-id') || form.id;
271
+ if (given) return given;
272
+ try {
273
+ const action = new URL(form.action, location.href);
274
+ return action.origin === location.origin ? action.pathname : `${action.origin}${action.pathname}`;
275
+ } catch {
276
+ return 'form';
277
+ }
278
+ };
279
+ const formSubmit = (form, scope)=>track('form_submit', scope || formScope(form));
204
280
  const expose = (exposure)=>{
205
281
  const current = getVisitor();
206
282
  if (!current || paused()) return null;
@@ -212,13 +288,15 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
212
288
  ...exposure
213
289
  }, key);
214
290
  };
215
- const autoTrack = ({ pageViews = true, clicks = true } = {})=>{
291
+ const autoTrack = ({ pageViews = true, clicks = true, outbound = false, downloads = false, forms = false } = {})=>{
216
292
  if (!isBrowser()) return ()=>{};
293
+ // A second call replaces the first instead of stacking listeners, so a
294
+ // site can add outbound/download/form tracking to the script's autoTrack.
295
+ stopAutoTrack?.();
217
296
  const stops = [];
218
297
  if (pageViews) {
219
298
  // One page view per path: replaceState for a query or hash change
220
299
  // isn't a new page.
221
- let lastPath = null;
222
300
  const onNavigate = ()=>{
223
301
  if (location.pathname === lastPath) return;
224
302
  lastPath = location.pathname;
@@ -253,9 +331,54 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
253
331
  document.addEventListener('click', onClick, true);
254
332
  stops.push(()=>document.removeEventListener('click', onClick, true));
255
333
  }
256
- return ()=>{
257
- for (const stop of stops)stop();
334
+ if (outbound || downloads) {
335
+ const onLinkClick = (event)=>{
336
+ const target = event.target;
337
+ const link = target?.closest?.('a[href]');
338
+ if (!link) return;
339
+ let url;
340
+ try {
341
+ url = new URL(link.href, location.href);
342
+ } catch {
343
+ return;
344
+ }
345
+ // Only http(s): a data: URL's path is its whole payload, and
346
+ // mailto:/tel: aren't destinations.
347
+ if (!/^https?:$/.test(url.protocol)) return;
348
+ const id = link.closest('[data-numbers-id]')?.getAttribute('data-numbers-id');
349
+ const fileName = url.pathname.split('/').pop() || url.pathname;
350
+ const extension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : '';
351
+ if (downloads && isDownload(url, link)) track('file_download', id ?? fileName, id ? undefined : {
352
+ params: {
353
+ file_name: extension ? fileName.slice(0, -(extension.length + 1)) : fileName,
354
+ ...extension && {
355
+ file_extension: extension
356
+ }
357
+ }
358
+ });
359
+ else if (outbound && url.origin !== location.origin) track('outbound_click', id ?? `${url.origin}${url.pathname}`, id ? undefined : {
360
+ params: {
361
+ link_domain: url.hostname
362
+ }
363
+ });
364
+ };
365
+ document.addEventListener('click', onLinkClick, true);
366
+ stops.push(()=>document.removeEventListener('click', onLinkClick, true));
367
+ }
368
+ if (forms) {
369
+ const onSubmit = (event)=>{
370
+ const form = event.target;
371
+ if (form instanceof HTMLFormElement) formSubmit(form);
372
+ };
373
+ document.addEventListener('submit', onSubmit, true);
374
+ stops.push(()=>document.removeEventListener('submit', onSubmit, true));
375
+ }
376
+ const stop = ()=>{
377
+ for (const fn of stops)fn();
378
+ if (stopAutoTrack === stop) stopAutoTrack = null;
258
379
  };
380
+ stopAutoTrack = stop;
381
+ return stop;
259
382
  };
260
383
  return {
261
384
  organizationId,
@@ -270,9 +393,10 @@ const createNumbers = ({ organizationId, spaceId, environment = 'production', en
270
393
  },
271
394
  track,
272
395
  pageView,
396
+ formSubmit,
273
397
  expose,
274
398
  autoTrack
275
399
  };
276
400
  };
277
401
 
278
- export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
402
+ export { DEFAULT_ENDPOINT, LEGACY_VISITOR_COOKIE, VISITOR_COOKIE, createNumbers, detectDevice };
@@ -1 +1 @@
1
- !/*! Obelism numbers (@obelism/numbers-sdk), MIT. https://numbers.obelism.studio */function(){"use strict";let e="https://numbers.obelism.studio/api/log",t="visitorId",r="visi",n=(e,t)=>e.get(t)?.slice(0,256)||void 0,i="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",o=/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/,a=()=>"u">typeof window&&"u">typeof document;((s,l)=>{if(!l||s.numbers)return;let u=l.dataset.organization;if(!u)return;let d=(({organizationId:s,spaceId:l,environment:u="production",endpoint:d=e,device:c,disableWarnings:p=!1})=>{let m=null,h=c,f=d,w=0,b=!0,g=new Set,v=new Set,y=new Set,$=(e,t)=>{!(p||y.has(e))&&(y.add(e),"u">typeof console&&console.warn(`[numbers] ${t}`))},S=()=>a()?(m??(m=(()=>{let e,n,o,a=(e=document.cookie.split("; ").find(e=>e.split("=")[0]===t))?e.split("=")[1]??"":"",s=2===(n=a.split("_")).length&&n[0]===r&&26===n[1].length,l=s?a:(()=>{let e=new Uint8Array(26);crypto.getRandomValues(e);let t="";for(let r of e)t+=i[r%i.length];return`${r}_${t}`})();return o=new Date(Date.now()+2592e6),document.cookie=`${t}=${l};path=/;expires=${o.toUTCString()};SameSite=Lax`+("https:"===location.protocol?";Secure":""),{id:l,recurring:s}})()),m):null,k=(e,t)=>{let r,n,i;return h??(r=navigator.userAgent.toLowerCase(),n=/ipad|tablet|android(?!.*mobile)/.test(r)?"tablet":/mobi|iphone|ipod|android/.test(r)?"mobile":"desktop",h={pointer:"desktop"===n?"fine":"coarse",device:n,browser:/edg\//.test(r)?"edge":/samsungbrowser/.test(r)?"samsung internet":/instagram|fban|fbav|tiktok|snapchat|wechat|line\//.test(r)?"social":/firefox|fxios/.test(r)?"firefox":/chrome|crios|chromium/.test(r)?"chrome":/safari/.test(r)?"safari":"other",os:/iphone|ipad|ipod/.test(r)?"ios":/android/.test(r)?"android":/windows/.test(r)?"windows":/mac os/.test(r)?"mac os":"unix"}),{type:e,organizationId:s,...l&&{spaceId:l},environment:u,visitorId:t.id,pointer:h.pointer,device:h.device,screen:(i=window.innerWidth)<=768?"small":i<=1024?"medium":i<=1200?"large":"huge",browser:h.browser,os:h.os,visitor:t.recurring?"recurring":"new"}},x=(e,t)=>{let r=JSON.stringify(e);return"u">typeof TextEncoder&&new TextEncoder().encode(r).length>8192&&$(`size:${t}`,`"${t}" is over 8192 bytes and will be refused: trim its \`params\`.`),fetch(f,{method:"POST",headers:{"Content-Type":"application/json"},body:r,keepalive:!0}).then(e=>(429===e.status&&(w=Date.now()+((e=>{if(!e)return;let t=Number(e);if(Number.isFinite(t))return Math.max(0,1e3*t);let r=Date.parse(e);return Number.isNaN(r)?void 0:Math.max(0,r-Date.now())})(e.headers.get("Retry-After"))??6e4)),e),()=>void 0)},_=(e,t,r,i)=>{let o,a,s,l,u,d=S();if(!d||Date.now()<w)return null;let{value:c,currency:p,params:m,dedupeKey:h}=r;if(void 0!==h){let t=`${e}:${h}`;if(g.has(t))return null;g.add(t)}let f={...k("event",d),...(o=new URLSearchParams(location.search),a={path:location.pathname.slice(0,1024)},s=n(o,"utm_source"),l=n(o,"utm_medium"),u=n(o,"utm_campaign"),s&&(a.utmSource=s),l&&(a.utmMedium=l),u&&(a.utmCampaign=u),a),event:e.slice(0,256),scope:t.slice(0,256)};return i&&(f.referrer=i),"number"==typeof c&&Number.isFinite(c)&&(f.value=c),p&&(f.currency=p.slice(0,8)),m&&"object"==typeof m&&!Array.isArray(m)&&(f.params=m),x(f,e)},L=(e,t,r={})=>a()?(o.test(e)||$(`name:${e}`,`"${e}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`),_(e,t||location.pathname,r)):null,D=()=>{if(!a())return null;let e=b?(()=>{if(document.referrer)try{let e=new URL(document.referrer);if(e.host===location.host)return;return`${e.origin}${e.pathname}`.slice(0,1024)}catch{return}})():void 0;return b=!1,_("page_view",location.pathname,{},e)};return{organizationId:s,spaceId:l,environment:u,visitor:S,setDevice:e=>{h=e},setEndpoint:e=>{f=e},track:L,pageView:D,expose:e=>{let t=S();if(!t||Date.now()<w)return null;let r=`${e.subjectKind}:${e.subjectId}`;return v.has(r)?null:(v.add(r),x({...k("exposure",t),...e},r))},autoTrack:({pageViews:e=!0,clicks:t=!0}={})=>{if(!a())return()=>{};let r=[];if(e){let e=null,t=()=>{location.pathname!==e&&(e=location.pathname,D())},n=e=>{let n=history[e],i=function(...e){let r=n.apply(this,e);return t(),r};history[e]=i,r.push(()=>{history[e]===i&&(history[e]=n)})};n("pushState"),n("replaceState"),window.addEventListener("popstate",t),r.push(()=>window.removeEventListener("popstate",t)),t()}if(t){let e=e=>{let t=e.target,r=t?.closest?.("[data-numbers-id]")?.getAttribute("data-numbers-id");r&&L("click",r)};document.addEventListener("click",e,!0),r.push(()=>document.removeEventListener("click",e,!0))}return()=>{for(let e of r)e()}}}})({organizationId:u,spaceId:l.dataset.space||void 0,environment:l.dataset.environment||"production",endpoint:l.dataset.endpoint||new URL("/api/log",l.src||location.href).href,disableWarnings:!0});return s.numbers=d,d.autoTrack()})(window,document.currentScript)}();
1
+ !/*! Obelism numbers (@obelism/numbers-sdk), MIT. https://numbers.obelism.studio */function(){"use strict";let e="https://numbers.obelism.studio/api/log",t="obelism_visitor_id",n="visitorId",i="visi",r="obelism_visitor_id",o=(e,t)=>e.get(t)?.slice(0,256)||void 0,s="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",a=e=>{let t=document.cookie.split("; ").find(t=>t.split("=")[0]===e);return t?t.split("=")[1]??"":""},l=(e,t)=>{let n=new Date(Date.now()+2592e6);document.cookie=`${e}=${t};path=/;expires=${n.toUTCString()};SameSite=Lax`+("https:"===location.protocol?";Secure":"")},u=()=>{let e=new Uint8Array(26);crypto.getRandomValues(e);let t="";for(let n of e)t+=s[n%s.length];return`${i}_${t}`},d=["7z","avi","csv","doc","docx","gz","mov","mp3","mp4","pdf","ppt","pptx","rar","tar","txt","wav","xls","xlsx","xml","zip"],c=/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/,m=()=>"u">typeof window&&"u">typeof document,p=["develop","staging","production"];((s,h)=>{if(!h||s.numbers)return;let f=void 0!==h.dataset.debug,g=e=>{f&&"u">typeof console&&console.warn(`[numbers] ${e}`)},b=h.dataset.organization;if(!b)return g("data-organization is missing; nothing is tracked.");b.startsWith("org_")||g(`data-organization "${b}" is not an org_… id.`);let v=h.dataset.space,w=v&&v.startsWith("space_")?v:void 0;v&&!w&&g(`data-space "${v}" is not a space_… id and will be ignored.`);let y=h.dataset.storage;y&&"session"!==y&&"none"!==y&&g(`data-storage "${y}" is not "session" or "none"; using a cookie.`);let $=h.dataset.environment;$&&!p.includes($)&&g(`data-environment "${$}" is not one of ${p.join(", ")}; using production.`);let _=(({organizationId:s,spaceId:p,environment:h="production",endpoint:f=e,device:g,disableWarnings:b=!1,storage:v="cookie"})=>{let w=null,y=g,$=f,_=0,k=!0,x=null,L=null,S=new Set,E=new Set,A=new Set,z=(e,t)=>{!(b||A.has(e))&&(A.add(e),"u">typeof console&&console.warn(`[numbers] ${t}`))},T=()=>m()?(w??(w=((e="cookie")=>{let o;if("none"===e)return{id:u(),recurring:!1};let s=(e=>{if("session"===e)try{return sessionStorage.getItem(r)??""}catch{return""}return a(t)||a(n)})(e),d=2===(o=s.split("_")).length&&o[0]===i&&26===o[1].length,c=d?s:u();return((e,i)=>{if("session"===e){try{sessionStorage.setItem(r,i)}catch{}return}l(t,i),l(n,i)})(e,c),{id:c,recurring:d}})(v)),w):null,D=(e,t)=>{let n,i,r;return y??(n=navigator.userAgent.toLowerCase(),i=/ipad|tablet|android(?!.*mobile)/.test(n)?"tablet":/mobi|iphone|ipod|android/.test(n)?"mobile":"desktop",y={pointer:"desktop"===i?"fine":"coarse",device:i,browser:/edg\//.test(n)?"edge":/samsungbrowser/.test(n)?"samsung internet":/instagram|fban|fbav|tiktok|snapchat|wechat|line\//.test(n)?"social":/firefox|fxios/.test(n)?"firefox":/chrome|crios|chromium/.test(n)?"chrome":/safari/.test(n)?"safari":"other",os:/iphone|ipad|ipod/.test(n)?"ios":/android/.test(n)?"android":/windows/.test(n)?"windows":/mac os/.test(n)?"mac os":"unix"}),{type:e,organizationId:s,...p&&{spaceId:p},environment:h,visitorId:t.id,pointer:y.pointer,device:y.device,screen:(r=window.innerWidth)<=768?"small":r<=1024?"medium":r<=1200?"large":"huge",browser:y.browser,os:y.os,visitor:t.recurring?"recurring":"new"}},U=(e,t)=>{let n=JSON.stringify(e);return"u">typeof TextEncoder&&new TextEncoder().encode(n).length>8192&&z(`size:${t}`,`"${t}" is over 8192 bytes and will be refused: trim its \`params\`.`),fetch($,{method:"POST",headers:{"Content-Type":"text/plain;charset=UTF-8"},body:n,keepalive:!0}).then(e=>(429===e.status&&(_=Date.now()+((e=>{if(!e)return;let t=Number(e);if(Number.isFinite(t))return Math.max(0,1e3*t);let n=Date.parse(e);return Number.isNaN(n)?void 0:Math.max(0,n-Date.now())})(e.headers.get("Retry-After"))??6e4)),e),()=>void 0)},N=(e,t,n,i)=>{let r,s,a,l,u,d=T();if(!d||Date.now()<_)return null;let{value:c,currency:m,params:p,dedupeKey:h}=n;if(void 0!==h){let t=`${e}:${h}`;if(S.has(t))return null;S.add(t)}let f={...D("event",d),...(r=new URLSearchParams(location.search),s={path:location.pathname.slice(0,1024)},a=o(r,"utm_source"),l=o(r,"utm_medium"),u=o(r,"utm_campaign"),a&&(s.utmSource=a),l&&(s.utmMedium=l),u&&(s.utmCampaign=u),s),event:e.slice(0,256),scope:t.slice(0,256)};return i&&(f.referrer=i),"number"==typeof c&&Number.isFinite(c)&&(f.value=c),m&&(f.currency=m.slice(0,8)),p&&"object"==typeof p&&!Array.isArray(p)&&(f.params=p),U(f,e)},R=(e,t,n={})=>m()?(c.test(e)||z(`name:${e}`,`"${e}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`),N(e,t||location.pathname,n)):null,C=()=>{if(!m())return null;let e=k?(()=>{if(document.referrer)try{let e=new URL(document.referrer);if(e.host===location.host)return;return`${e.origin}${e.pathname}`.slice(0,1024)}catch{return}})():void 0;return k=!1,N("page_view",location.pathname,{},e)},I=(e,t)=>R("form_submit",t||(e=>{let t=e.getAttribute("data-numbers-id")||e.id;if(t)return t;try{let t=new URL(e.action,location.href);return t.origin===location.origin?t.pathname:`${t.origin}${t.pathname}`}catch{return"form"}})(e));return{organizationId:s,spaceId:p,environment:h,visitor:T,setDevice:e=>{y=e},setEndpoint:e=>{$=e},track:R,pageView:C,formSubmit:I,expose:e=>{let t=T();if(!t||Date.now()<_)return null;let n=`${e.subjectKind}:${e.subjectId}`;return E.has(n)?null:(E.add(n),U({...D("exposure",t),...e},n))},autoTrack:({pageViews:e=!0,clicks:t=!0,outbound:n=!1,downloads:i=!1,forms:r=!1}={})=>{if(!m())return()=>{};L?.();let o=[];if(e){let e=()=>{location.pathname!==x&&(x=location.pathname,C())},t=t=>{let n=history[t],i=function(...t){let i=n.apply(this,t);return e(),i};history[t]=i,o.push(()=>{history[t]===i&&(history[t]=n)})};t("pushState"),t("replaceState"),window.addEventListener("popstate",e),o.push(()=>window.removeEventListener("popstate",e)),e()}if(t){let e=e=>{let t=e.target,n=t?.closest?.("[data-numbers-id]")?.getAttribute("data-numbers-id");n&&R("click",n)};document.addEventListener("click",e,!0),o.push(()=>document.removeEventListener("click",e,!0))}if(n||i){let e=e=>{let t,r,o=e.target,s=o?.closest?.("a[href]");if(!s)return;try{t=new URL(s.href,location.href)}catch{return}if(!/^https?:$/.test(t.protocol))return;let a=s.closest("[data-numbers-id]")?.getAttribute("data-numbers-id"),l=t.pathname.split("/").pop()||t.pathname,u=l.includes(".")?l.split(".").pop().toLowerCase():"";i&&(r=t,s.hasAttribute("download")||d.includes(r.pathname.split(".").pop()?.toLowerCase()??""))?R("file_download",a??l,a?void 0:{params:{file_name:u?l.slice(0,-(u.length+1)):l,...u&&{file_extension:u}}}):n&&t.origin!==location.origin&&R("outbound_click",a??`${t.origin}${t.pathname}`,a?void 0:{params:{link_domain:t.hostname}})};document.addEventListener("click",e,!0),o.push(()=>document.removeEventListener("click",e,!0))}if(r){let e=e=>{let t=e.target;t instanceof HTMLFormElement&&I(t)};document.addEventListener("submit",e,!0),o.push(()=>document.removeEventListener("submit",e,!0))}let s=()=>{for(let e of o)e();L===s&&(L=null)};return L=s,s}}})({organizationId:b,spaceId:w,environment:$||"production",endpoint:h.dataset.endpoint||(t=>{let n;try{n=new URL(t)}catch{return e}let i="localhost"===n.hostname||"127.0.0.1"===n.hostname;return"numbers.obelism.studio"===n.hostname||i?`${n.origin}/api/log`:e})(h.src),disableWarnings:!f,storage:"session"===y||"none"===y?y:"cookie"});return s.numbers=_,_.autoTrack()})(window,document.currentScript)}();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@obelism/numbers-sdk",
3
3
  "description": "Obelism numbers SDK: page views, events and clicks on tagged elements, sent to numbers.obelism.studio",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "keywords": [
6
6
  "analytics",
7
7
  "events",
@@ -48,13 +48,13 @@
48
48
  "@rollup/plugin-node-resolve": "^16.0.3",
49
49
  "@typescript/typescript6": "^6.0.2",
50
50
  "bunchee": "^7.0.2",
51
- "jsdom": "^30.1.2",
52
- "npm-run-all2": "^9.0.2",
53
- "oxlint": "^1.88.0",
54
- "rollup": "^4.64.7",
51
+ "jsdom": "^30.1.3",
52
+ "npm-run-all2": "^9.0.4",
53
+ "oxlint": "^1.90.0",
54
+ "rollup": "^4.65.1",
55
55
  "rollup-plugin-swc3": "^0.12.1",
56
56
  "typescript": "^7.0.2",
57
- "vitest": "^5.0.4"
57
+ "vitest": "^5.0.5"
58
58
  },
59
59
  "engines": {
60
60
  "node": ">=20"