@obelism/numbers-sdk 0.1.0 → 0.3.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
@@ -15,6 +15,7 @@ No bundler needed:
15
15
  defer
16
16
  src="https://numbers.obelism.studio/script.js"
17
17
  data-organization="org_…"
18
+ data-space="space_…"
18
19
  data-environment="production"
19
20
  ></script>
20
21
  ```
@@ -27,9 +28,17 @@ client on `window.numbers`:
27
28
  numbers.track('sign_up', 'footer_form')
28
29
  ```
29
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
+
34
+ `data-space` names the space (one site or project of the organization) the
35
+ events belong to; without it they go to the organization's first space.
30
36
  `data-endpoint="https://…"` sends somewhere else, e.g. a proxy on your own
31
- domain. npm CDNs serve the same file from
32
- `@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 client's development warnings (snake_case
39
+ names, an 8 KB body, a `data-space` it can't use), and
40
+ `data-storage="session"` (or `"none"`) counts without a cookie. npm CDNs
41
+ serve the same file from `@obelism/numbers-sdk/dist/script.global.js`.
33
42
 
34
43
  ## Module
35
44
 
@@ -42,13 +51,15 @@ import { createNumbers } from '@obelism/numbers-sdk'
42
51
 
43
52
  const numbers = createNumbers({
44
53
  organizationId: 'org_…',
54
+ spaceId: 'space_…', // optional: without it, the organization's first space
45
55
  environment: 'production', // default; or 'develop', 'staging'
46
56
  // endpoint: 'https://numbers.obelism.studio/api/log',
47
57
  // disableWarnings: true,
48
58
  })
49
59
 
50
60
  // Page views now and on every navigation, and clicks on tagged elements.
51
- const stop = numbers.autoTrack() // { pageViews?: boolean, clicks?: boolean }
61
+ const stop = numbers.autoTrack()
62
+ // { pageViews?, clicks?, outbound?, downloads?, forms? }
52
63
 
53
64
  numbers.track('add_to_cart', 'product_page', {
54
65
  value: 59.95,
@@ -56,9 +67,16 @@ numbers.track('add_to_cart', 'product_page', {
56
67
  params: { ecommerce: { items: [{ item_id: 'sku_1' }] } },
57
68
  })
58
69
  numbers.track('view_item', 'list', { dedupeKey: 'sku_1' }) // once per key
70
+ numbers.formSubmit(form, 'newsletter') // or autoTrack({ forms: true })
59
71
  numbers.pageView() // by hand, without autoTrack
60
72
  ```
61
73
 
74
+ `autoTrack({ outbound: true })` sends `outbound_click` for links to another
75
+ site, `downloads: true` sends `file_download` for links to a file, and
76
+ `forms: true` sends `form_submit` for each submitted form. Calling
77
+ `autoTrack` again replaces the previous listeners, so it doesn't double
78
+ count.
79
+
62
80
  The client is safe to create during server rendering: outside the browser
63
81
  every call does nothing and returns `null`.
64
82
 
@@ -89,12 +107,20 @@ its visual editor shows each block's click rate.
89
107
 
90
108
  ### Visitors
91
109
 
92
- A visitor is a random id in the `visitorId` cookie (`visi_` and 26
110
+ A visitor is a random id in the `obelism_visitor_id` cookie (`visi_` and 26
93
111
  characters, 30 days from the last visit), the same cookie the improve SDK
94
- uses. `numbers.visitor()` returns `{ id, recurring }`. The device categories
112
+ uses. The old `visitorId` cookie is read and written too, for a deprecation
113
+ window, so a site that still runs an older improve SDK keeps one visitor.
114
+ `numbers.visitor()` returns `{ id, recurring }`. The device categories
95
115
  (pointer, device, browser, os, screen size) come from the user agent; pass
96
116
  `device` to `createNumbers` (or call `setDevice`) to use your own.
97
117
 
118
+ For a site that counts without asking for consent, `storage: 'session'`
119
+ keeps the id in sessionStorage for the tab and `storage: 'none'` makes a new
120
+ one on every client (so every full load in a page that isn't an SPA): neither
121
+ sets a cookie. The script takes the same as
122
+ `data-storage`.
123
+
98
124
  ### Limits
99
125
 
100
126
  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
@@ -12,6 +24,11 @@ type Device = {
12
24
  };
13
25
  type NumbersOptions = {
14
26
  organizationId: string;
27
+ /**
28
+ * The space (`space_…`) the events belong to: one site or project of the
29
+ * organization. Without it, the organization's first space.
30
+ */
31
+ spaceId?: string | undefined;
15
32
  /** Default `production`. */
16
33
  environment?: Environment;
17
34
  /**
@@ -23,6 +40,13 @@ type NumbersOptions = {
23
40
  device?: Device;
24
41
  /** Silence the development warnings (e.g. a name that isn't snake_case). */
25
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;
26
50
  };
27
51
  /**
28
52
  * Event names GA4 / Google Tag Manager recommend. Reusing them lines your
@@ -60,6 +84,7 @@ type Exposure = {
60
84
  type BeaconCommon<T extends 'event' | 'exposure'> = {
61
85
  type: T;
62
86
  organizationId: string;
87
+ spaceId?: string;
63
88
  environment: Environment;
64
89
  visitorId: string;
65
90
  pointer: string;
@@ -86,12 +111,6 @@ type EventBeacon = BeaconCommon<'event'> & PageContext & {
86
111
  };
87
112
  type ExposureBeacon = BeaconCommon<'exposure'> & Exposure;
88
113
 
89
- type Visitor = {
90
- id: string;
91
- /** Whether the visitor had a cookie already: `recurring` on the server. */
92
- recurring: boolean;
93
- };
94
-
95
114
  type AutoTrackOptions = {
96
115
  /** A page view now and on every client-side navigation. Default on. */
97
116
  pageViews?: boolean;
@@ -100,11 +119,27 @@ type AutoTrackOptions = {
100
119
  * element with `data-numbers-id="…"`. Default on.
101
120
  */
102
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;
103
137
  };
104
138
  /** What a send resolves to: the response, or nothing when it failed. */
105
139
  type Sent = Promise<Response | undefined>;
106
140
  type NumbersClient = {
107
141
  readonly organizationId: string;
142
+ readonly spaceId: string | undefined;
108
143
  readonly environment: Environment;
109
144
  /** The visitor, from the cookie or new. `null` outside the browser. */
110
145
  visitor: () => Visitor | null;
@@ -119,17 +154,21 @@ type NumbersClient = {
119
154
  track: (event: EventName, scope?: string, options?: TrackOptions) => Sent | null;
120
155
  /** Sends a page view of the current page. */
121
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;
122
159
  /** Sends a test's or flag's assignment, once per subject per client. */
123
160
  expose: (exposure: Exposure) => Sent | null;
124
- /** Starts page views and click tracking; returns a function that stops them. */
161
+ /** Starts automatic tracking; returns a function that stops it. */
125
162
  autoTrack: (options?: AutoTrackOptions) => () => void;
126
163
  };
127
- declare const createNumbers: ({ organizationId, environment, endpoint, device: givenDevice, disableWarnings, }: NumbersOptions) => NumbersClient;
164
+ declare const createNumbers: ({ organizationId, spaceId, environment, endpoint, device: givenDevice, disableWarnings, storage, }: NumbersOptions) => NumbersClient;
128
165
 
129
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";
130
169
 
131
170
  declare const detectDevice: (userAgent: string) => Device;
132
171
 
133
- export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
134
- 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 };
135
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,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,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;;ACtFzD,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,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,qFAAqF,cAAc,KAAK,aAAa;;AClChJ,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
@@ -12,6 +24,11 @@ type Device = {
12
24
  };
13
25
  type NumbersOptions = {
14
26
  organizationId: string;
27
+ /**
28
+ * The space (`space_…`) the events belong to: one site or project of the
29
+ * organization. Without it, the organization's first space.
30
+ */
31
+ spaceId?: string | undefined;
15
32
  /** Default `production`. */
16
33
  environment?: Environment;
17
34
  /**
@@ -23,6 +40,13 @@ type NumbersOptions = {
23
40
  device?: Device;
24
41
  /** Silence the development warnings (e.g. a name that isn't snake_case). */
25
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;
26
50
  };
27
51
  /**
28
52
  * Event names GA4 / Google Tag Manager recommend. Reusing them lines your
@@ -60,6 +84,7 @@ type Exposure = {
60
84
  type BeaconCommon<T extends 'event' | 'exposure'> = {
61
85
  type: T;
62
86
  organizationId: string;
87
+ spaceId?: string;
63
88
  environment: Environment;
64
89
  visitorId: string;
65
90
  pointer: string;
@@ -86,12 +111,6 @@ type EventBeacon = BeaconCommon<'event'> & PageContext & {
86
111
  };
87
112
  type ExposureBeacon = BeaconCommon<'exposure'> & Exposure;
88
113
 
89
- type Visitor = {
90
- id: string;
91
- /** Whether the visitor had a cookie already: `recurring` on the server. */
92
- recurring: boolean;
93
- };
94
-
95
114
  type AutoTrackOptions = {
96
115
  /** A page view now and on every client-side navigation. Default on. */
97
116
  pageViews?: boolean;
@@ -100,11 +119,27 @@ type AutoTrackOptions = {
100
119
  * element with `data-numbers-id="…"`. Default on.
101
120
  */
102
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;
103
137
  };
104
138
  /** What a send resolves to: the response, or nothing when it failed. */
105
139
  type Sent = Promise<Response | undefined>;
106
140
  type NumbersClient = {
107
141
  readonly organizationId: string;
142
+ readonly spaceId: string | undefined;
108
143
  readonly environment: Environment;
109
144
  /** The visitor, from the cookie or new. `null` outside the browser. */
110
145
  visitor: () => Visitor | null;
@@ -119,17 +154,21 @@ type NumbersClient = {
119
154
  track: (event: EventName, scope?: string, options?: TrackOptions) => Sent | null;
120
155
  /** Sends a page view of the current page. */
121
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;
122
159
  /** Sends a test's or flag's assignment, once per subject per client. */
123
160
  expose: (exposure: Exposure) => Sent | null;
124
- /** Starts page views and click tracking; returns a function that stops them. */
161
+ /** Starts automatic tracking; returns a function that stops it. */
125
162
  autoTrack: (options?: AutoTrackOptions) => () => void;
126
163
  };
127
- declare const createNumbers: ({ organizationId, environment, endpoint, device: givenDevice, disableWarnings, }: NumbersOptions) => NumbersClient;
164
+ declare const createNumbers: ({ organizationId, spaceId, environment, endpoint, device: givenDevice, disableWarnings, storage, }: NumbersOptions) => NumbersClient;
128
165
 
129
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";
130
169
 
131
170
  declare const detectDevice: (userAgent: string) => Device;
132
171
 
133
- export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
134
- 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 };
135
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,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,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;;ACtFzD,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,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,qFAAqF,cAAc,KAAK,aAAa;;AClChJ,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,14 @@ 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, 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
+ let stopAutoTrack = null;
122
182
  const sentKeys = new Set();
123
183
  const exposed = new Set();
124
184
  const warned = new Set();
@@ -129,7 +189,7 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
129
189
  };
130
190
  const getVisitor = ()=>{
131
191
  if (!isBrowser()) return null;
132
- visitor ??= loadVisitor();
192
+ visitor ??= loadVisitor(storage);
133
193
  return visitor;
134
194
  };
135
195
  const common = (type, current)=>{
@@ -137,6 +197,9 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
137
197
  return {
138
198
  type,
139
199
  organizationId,
200
+ ...spaceId && {
201
+ spaceId
202
+ },
140
203
  environment,
141
204
  visitorId: current.id,
142
205
  pointer: device.pointer,
@@ -153,8 +216,10 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
153
216
  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\`.`);
154
217
  return fetch(url, {
155
218
  method: 'POST',
219
+ // A CORS-safelisted type, so a browser sends the event without a
220
+ // preflight; numbers parses the body as JSON either way.
156
221
  headers: {
157
- 'Content-Type': 'application/json'
222
+ 'Content-Type': 'text/plain;charset=UTF-8'
158
223
  },
159
224
  body: serialized,
160
225
  keepalive: true
@@ -200,6 +265,17 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
200
265
  firstPageView = false;
201
266
  return sendEvent('page_view', location.pathname, {}, referrer);
202
267
  };
268
+ const formScope = (form)=>{
269
+ const given = form.getAttribute('data-numbers-id') || form.id;
270
+ if (given) return given;
271
+ try {
272
+ const action = new URL(form.action, location.href);
273
+ return action.origin === location.origin ? action.pathname : `${action.origin}${action.pathname}`;
274
+ } catch {
275
+ return 'form';
276
+ }
277
+ };
278
+ const formSubmit = (form, scope)=>track('form_submit', scope || formScope(form));
203
279
  const expose = (exposure)=>{
204
280
  const current = getVisitor();
205
281
  if (!current || paused()) return null;
@@ -211,8 +287,11 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
211
287
  ...exposure
212
288
  }, key);
213
289
  };
214
- const autoTrack = ({ pageViews = true, clicks = true } = {})=>{
290
+ const autoTrack = ({ pageViews = true, clicks = true, outbound = false, downloads = false, forms = false } = {})=>{
215
291
  if (!isBrowser()) return ()=>{};
292
+ // A second call replaces the first instead of stacking listeners, so a
293
+ // site can add outbound/download/form tracking to the script's autoTrack.
294
+ stopAutoTrack?.();
216
295
  const stops = [];
217
296
  if (pageViews) {
218
297
  // One page view per path: replaceState for a query or hash change
@@ -252,12 +331,42 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
252
331
  document.addEventListener('click', onClick, true);
253
332
  stops.push(()=>document.removeEventListener('click', onClick, true));
254
333
  }
255
- return ()=>{
256
- 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
+ const id = link.closest('[data-numbers-id]')?.getAttribute('data-numbers-id');
346
+ if (downloads && isDownload(url, link)) track('file_download', id ?? url.pathname.split('/').pop() ?? url.pathname);
347
+ else if (outbound && url.origin !== location.origin && /^https?:$/.test(url.protocol)) track('outbound_click', id ?? `${url.origin}${url.pathname}`);
348
+ };
349
+ document.addEventListener('click', onLinkClick, true);
350
+ stops.push(()=>document.removeEventListener('click', onLinkClick, true));
351
+ }
352
+ if (forms) {
353
+ const onSubmit = (event)=>{
354
+ const form = event.target;
355
+ if (form instanceof HTMLFormElement) formSubmit(form);
356
+ };
357
+ document.addEventListener('submit', onSubmit, true);
358
+ stops.push(()=>document.removeEventListener('submit', onSubmit, true));
359
+ }
360
+ const stop = ()=>{
361
+ for (const fn of stops)fn();
362
+ if (stopAutoTrack === stop) stopAutoTrack = null;
257
363
  };
364
+ stopAutoTrack = stop;
365
+ return stop;
258
366
  };
259
367
  return {
260
368
  organizationId,
369
+ spaceId,
261
370
  environment,
262
371
  visitor: getVisitor,
263
372
  setDevice: (next)=>{
@@ -268,11 +377,14 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
268
377
  },
269
378
  track,
270
379
  pageView,
380
+ formSubmit,
271
381
  expose,
272
382
  autoTrack
273
383
  };
274
384
  };
275
385
 
276
386
  exports.DEFAULT_ENDPOINT = DEFAULT_ENDPOINT;
387
+ exports.LEGACY_VISITOR_COOKIE = LEGACY_VISITOR_COOKIE;
388
+ exports.VISITOR_COOKIE = VISITOR_COOKIE;
277
389
  exports.createNumbers = createNumbers;
278
390
  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,14 @@ 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, 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
+ let stopAutoTrack = null;
120
180
  const sentKeys = new Set();
121
181
  const exposed = new Set();
122
182
  const warned = new Set();
@@ -127,7 +187,7 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
127
187
  };
128
188
  const getVisitor = ()=>{
129
189
  if (!isBrowser()) return null;
130
- visitor ??= loadVisitor();
190
+ visitor ??= loadVisitor(storage);
131
191
  return visitor;
132
192
  };
133
193
  const common = (type, current)=>{
@@ -135,6 +195,9 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
135
195
  return {
136
196
  type,
137
197
  organizationId,
198
+ ...spaceId && {
199
+ spaceId
200
+ },
138
201
  environment,
139
202
  visitorId: current.id,
140
203
  pointer: device.pointer,
@@ -151,8 +214,10 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
151
214
  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\`.`);
152
215
  return fetch(url, {
153
216
  method: 'POST',
217
+ // A CORS-safelisted type, so a browser sends the event without a
218
+ // preflight; numbers parses the body as JSON either way.
154
219
  headers: {
155
- 'Content-Type': 'application/json'
220
+ 'Content-Type': 'text/plain;charset=UTF-8'
156
221
  },
157
222
  body: serialized,
158
223
  keepalive: true
@@ -198,6 +263,17 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
198
263
  firstPageView = false;
199
264
  return sendEvent('page_view', location.pathname, {}, referrer);
200
265
  };
266
+ const formScope = (form)=>{
267
+ const given = form.getAttribute('data-numbers-id') || form.id;
268
+ if (given) return given;
269
+ try {
270
+ const action = new URL(form.action, location.href);
271
+ return action.origin === location.origin ? action.pathname : `${action.origin}${action.pathname}`;
272
+ } catch {
273
+ return 'form';
274
+ }
275
+ };
276
+ const formSubmit = (form, scope)=>track('form_submit', scope || formScope(form));
201
277
  const expose = (exposure)=>{
202
278
  const current = getVisitor();
203
279
  if (!current || paused()) return null;
@@ -209,8 +285,11 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
209
285
  ...exposure
210
286
  }, key);
211
287
  };
212
- const autoTrack = ({ pageViews = true, clicks = true } = {})=>{
288
+ const autoTrack = ({ pageViews = true, clicks = true, outbound = false, downloads = false, forms = false } = {})=>{
213
289
  if (!isBrowser()) return ()=>{};
290
+ // A second call replaces the first instead of stacking listeners, so a
291
+ // site can add outbound/download/form tracking to the script's autoTrack.
292
+ stopAutoTrack?.();
214
293
  const stops = [];
215
294
  if (pageViews) {
216
295
  // One page view per path: replaceState for a query or hash change
@@ -250,12 +329,42 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
250
329
  document.addEventListener('click', onClick, true);
251
330
  stops.push(()=>document.removeEventListener('click', onClick, true));
252
331
  }
253
- return ()=>{
254
- for (const stop of stops)stop();
332
+ if (outbound || downloads) {
333
+ const onLinkClick = (event)=>{
334
+ const target = event.target;
335
+ const link = target?.closest?.('a[href]');
336
+ if (!link) return;
337
+ let url;
338
+ try {
339
+ url = new URL(link.href, location.href);
340
+ } catch {
341
+ return;
342
+ }
343
+ const id = link.closest('[data-numbers-id]')?.getAttribute('data-numbers-id');
344
+ if (downloads && isDownload(url, link)) track('file_download', id ?? url.pathname.split('/').pop() ?? url.pathname);
345
+ else if (outbound && url.origin !== location.origin && /^https?:$/.test(url.protocol)) track('outbound_click', id ?? `${url.origin}${url.pathname}`);
346
+ };
347
+ document.addEventListener('click', onLinkClick, true);
348
+ stops.push(()=>document.removeEventListener('click', onLinkClick, true));
349
+ }
350
+ if (forms) {
351
+ const onSubmit = (event)=>{
352
+ const form = event.target;
353
+ if (form instanceof HTMLFormElement) formSubmit(form);
354
+ };
355
+ document.addEventListener('submit', onSubmit, true);
356
+ stops.push(()=>document.removeEventListener('submit', onSubmit, true));
357
+ }
358
+ const stop = ()=>{
359
+ for (const fn of stops)fn();
360
+ if (stopAutoTrack === stop) stopAutoTrack = null;
255
361
  };
362
+ stopAutoTrack = stop;
363
+ return stop;
256
364
  };
257
365
  return {
258
366
  organizationId,
367
+ spaceId,
259
368
  environment,
260
369
  visitor: getVisitor,
261
370
  setDevice: (next)=>{
@@ -266,9 +375,10 @@ const createNumbers = ({ organizationId, environment = 'production', endpoint =
266
375
  },
267
376
  track,
268
377
  pageView,
378
+ formSubmit,
269
379
  expose,
270
380
  autoTrack
271
381
  };
272
382
  };
273
383
 
274
- export { DEFAULT_ENDPOINT, createNumbers, detectDevice };
384
+ 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 c=(({organizationId:s,environment:l="production",endpoint:u=e,device:c,disableWarnings:d=!1})=>{let p=null,m=c,h=u,f=0,w=!0,b=new Set,g=new Set,v=new Set,y=(e,t)=>{!(d||v.has(e))&&(v.add(e),"u">typeof console&&console.warn(`[numbers] ${t}`))},$=()=>a()?(p??(p=(()=>{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}})()),p):null,S=(e,t)=>{let r,n,i;return m??(r=navigator.userAgent.toLowerCase(),n=/ipad|tablet|android(?!.*mobile)/.test(r)?"tablet":/mobi|iphone|ipod|android/.test(r)?"mobile":"desktop",m={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,environment:l,visitorId:t.id,pointer:m.pointer,device:m.device,screen:(i=window.innerWidth)<=768?"small":i<=1024?"medium":i<=1200?"large":"huge",browser:m.browser,os:m.os,visitor:t.recurring?"recurring":"new"}},k=(e,t)=>{let r=JSON.stringify(e);return"u">typeof TextEncoder&&new TextEncoder().encode(r).length>8192&&y(`size:${t}`,`"${t}" is over 8192 bytes and will be refused: trim its \`params\`.`),fetch(h,{method:"POST",headers:{"Content-Type":"application/json"},body:r,keepalive:!0}).then(e=>(429===e.status&&(f=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)},x=(e,t,r,i)=>{let o,a,s,l,u,c=$();if(!c||Date.now()<f)return null;let{value:d,currency:p,params:m,dedupeKey:h}=r;if(void 0!==h){let t=`${e}:${h}`;if(b.has(t))return null;b.add(t)}let w={...S("event",c),...(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&&(w.referrer=i),"number"==typeof d&&Number.isFinite(d)&&(w.value=d),p&&(w.currency=p.slice(0,8)),m&&"object"==typeof m&&!Array.isArray(m)&&(w.params=m),k(w,e)},_=(e,t,r={})=>a()?(o.test(e)||y(`name:${e}`,`"${e}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`),x(e,t||location.pathname,r)):null,L=()=>{if(!a())return null;let e=w?(()=>{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 w=!1,x("page_view",location.pathname,{},e)};return{organizationId:s,environment:l,visitor:$,setDevice:e=>{m=e},setEndpoint:e=>{h=e},track:_,pageView:L,expose:e=>{let t=$();if(!t||Date.now()<f)return null;let r=`${e.subjectKind}:${e.subjectId}`;return g.has(r)?null:(g.add(r),k({...S("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,L())},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&&_("click",r)};document.addEventListener("click",e,!0),r.push(()=>document.removeEventListener("click",e,!0))}return()=>{for(let e of r)e()}}}})({organizationId:u,environment:l.dataset.environment||"production",endpoint:l.dataset.endpoint||new URL("/api/log",l.src||location.href).href,disableWarnings:!0});return s.numbers=c,c.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",r="visi",o="obelism_visitor_id",i=(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`${r}_${t}`},c=["7z","avi","csv","doc","docx","gz","mov","mp3","mp4","pdf","ppt","pptx","rar","tar","txt","wav","xls","xlsx","xml","zip"],d=/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/,m=()=>"u">typeof window&&"u">typeof document;((s,p)=>{var h;if(!p||s.numbers)return;let f=p.dataset.organization;if(!f)return;let g=void 0!==p.dataset.debug;!f.startsWith("org_")&&"u">typeof console&&console.warn(`[numbers] data-organization "${f}" is not an org_… id.`);let b=p.dataset.space,w=b&&b.startsWith("space_")?b:void 0;b&&!w&&"u">typeof console&&console.warn(`[numbers] data-space "${b}" is not a space_… id and will be ignored.`);let v=p.dataset.storage;v&&"session"!==v&&"none"!==v&&(h=`data-storage "${v}" is not "session" or "none"; using a cookie.`,g&&"u">typeof console&&console.warn(`[numbers] ${h}`));let y=(({organizationId:s,spaceId:p,environment:h="production",endpoint:f=e,device:g,disableWarnings:b=!1,storage:w="cookie"})=>{let v=null,y=g,$=f,_=0,k=!0,x=null,L=new Set,S=new Set,E=new Set,A=(e,t)=>{!(b||E.has(e))&&(E.add(e),"u">typeof console&&console.warn(`[numbers] ${t}`))},T=()=>m()?(v??(v=((e="cookie")=>{let i;if("none"===e)return{id:u(),recurring:!1};let s=(e=>{if("session"===e)try{return sessionStorage.getItem(o)??""}catch{return""}return a(t)||a(n)})(e),c=2===(i=s.split("_")).length&&i[0]===r&&26===i[1].length,d=c?s:u();return((e,r)=>{if("session"===e){try{sessionStorage.setItem(o,r)}catch{}return}l(t,r),l(n,r)})(e,d),{id:d,recurring:c}})(w)),v):null,z=(e,t)=>{let n,r,o;return y??(n=navigator.userAgent.toLowerCase(),r=/ipad|tablet|android(?!.*mobile)/.test(n)?"tablet":/mobi|iphone|ipod|android/.test(n)?"mobile":"desktop",y={pointer:"desktop"===r?"fine":"coarse",device:r,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:(o=window.innerWidth)<=768?"small":o<=1024?"medium":o<=1200?"large":"huge",browser:y.browser,os:y.os,visitor:t.recurring?"recurring":"new"}},D=(e,t)=>{let n=JSON.stringify(e);return"u">typeof TextEncoder&&new TextEncoder().encode(n).length>8192&&A(`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)},U=(e,t,n,r)=>{let o,s,a,l,u,c=T();if(!c||Date.now()<_)return null;let{value:d,currency:m,params:p,dedupeKey:h}=n;if(void 0!==h){let t=`${e}:${h}`;if(L.has(t))return null;L.add(t)}let f={...z("event",c),...(o=new URLSearchParams(location.search),s={path:location.pathname.slice(0,1024)},a=i(o,"utm_source"),l=i(o,"utm_medium"),u=i(o,"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 r&&(f.referrer=r),"number"==typeof d&&Number.isFinite(d)&&(f.value=d),m&&(f.currency=m.slice(0,8)),p&&"object"==typeof p&&!Array.isArray(p)&&(f.params=p),D(f,e)},N=(e,t,n={})=>m()?(d.test(e)||A(`name:${e}`,`"${e}" isn't snake_case. Prefer GA4-style names like "add_to_cart" or "sign_up"; \`disableWarnings: true\` silences this.`),U(e,t||location.pathname,n)):null,R=()=>{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,U("page_view",location.pathname,{},e)},C=(e,t)=>N("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:N,pageView:R,formSubmit:C,expose:e=>{let t=T();if(!t||Date.now()<_)return null;let n=`${e.subjectKind}:${e.subjectId}`;return S.has(n)?null:(S.add(n),D({...z("exposure",t),...e},n))},autoTrack:({pageViews:e=!0,clicks:t=!0,outbound:n=!1,downloads:r=!1,forms:o=!1}={})=>{if(!m())return()=>{};x?.();let i=[];if(e){let e=null,t=()=>{location.pathname!==e&&(e=location.pathname,R())},n=e=>{let n=history[e],r=function(...e){let r=n.apply(this,e);return t(),r};history[e]=r,i.push(()=>{history[e]===r&&(history[e]=n)})};n("pushState"),n("replaceState"),window.addEventListener("popstate",t),i.push(()=>window.removeEventListener("popstate",t)),t()}if(t){let e=e=>{let t=e.target,n=t?.closest?.("[data-numbers-id]")?.getAttribute("data-numbers-id");n&&N("click",n)};document.addEventListener("click",e,!0),i.push(()=>document.removeEventListener("click",e,!0))}if(n||r){let e=e=>{let t,o,i=e.target,s=i?.closest?.("a[href]");if(!s)return;try{t=new URL(s.href,location.href)}catch{return}let a=s.closest("[data-numbers-id]")?.getAttribute("data-numbers-id");r&&(o=t,s.hasAttribute("download")||c.includes(o.pathname.split(".").pop()?.toLowerCase()??""))?N("file_download",a??t.pathname.split("/").pop()??t.pathname):n&&t.origin!==location.origin&&/^https?:$/.test(t.protocol)&&N("outbound_click",a??`${t.origin}${t.pathname}`)};document.addEventListener("click",e,!0),i.push(()=>document.removeEventListener("click",e,!0))}if(o){let e=e=>{let t=e.target;t instanceof HTMLFormElement&&C(t)};document.addEventListener("submit",e,!0),i.push(()=>document.removeEventListener("submit",e,!0))}let s=()=>{for(let e of i)e();x===s&&(x=null)};return x=s,s}}})({organizationId:f,spaceId:w,environment:p.dataset.environment||"production",endpoint:p.dataset.endpoint||(t=>{let n;try{n=new URL(t)}catch{return e}let r="localhost"===n.hostname||"127.0.0.1"===n.hostname;return"numbers.obelism.studio"===n.hostname||r?`${n.origin}/api/log`:e})(p.src),disableWarnings:!g,storage:"session"===v||"none"===v?v:"cookie"});return s.numbers=y,y.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.1.0",
4
+ "version": "0.3.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"