@tellegen/analytics 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -0
- package/README.md +57 -2
- package/dist/client.d.ts +65 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +155 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/sender.d.ts +48 -0
- package/dist/sender.d.ts.map +1 -0
- package/dist/sender.js +52 -0
- package/dist/sender.js.map +1 -0
- package/dist/vocabulary.d.ts +47 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +93 -0
- package/dist/vocabulary.js.map +1 -0
- package/package.json +45 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Samuel Talkington and contributors
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@tellegen/analytics`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cookieless usage analytics for Tellegen sites, built so that only an
|
|
4
|
+
allowlisted vocabulary ever leaves the browser. The package has no
|
|
5
|
+
dependencies and no UI framework. It does two things:
|
|
6
|
+
|
|
7
|
+
- **Sanitise.** A `Vocabulary` lists the page paths, event names, property
|
|
8
|
+
keys and property values a site may report. Every payload is rebuilt from
|
|
9
|
+
it: an unlisted key or value is dropped, numbers become clamped
|
|
10
|
+
non-negative integers, and any other path is reported as the root. Queries,
|
|
11
|
+
fragments, titles, referrers, screen sizes and languages are never sent.
|
|
12
|
+
- **Send.** A sender posts each payload straight to Umami Cloud's collection
|
|
13
|
+
address (`https://gateway.umami.is/api/send`) from the site's own code. No
|
|
14
|
+
third-party script runs on the page, so a content security policy keeps
|
|
15
|
+
`script-src 'self'` and adds one host to `connect-src`.
|
|
16
|
+
|
|
17
|
+
Nothing is sent unless the page is served over https from a listed host, Do
|
|
18
|
+
Not Track and Global Privacy Control are off, and the visitor has not opted
|
|
19
|
+
out. The opt-out is the only thing written to storage, and only when asked.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @tellegen/analytics
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createAnalytics, localStorageOptOut, checkVocabulary } from '@tellegen/analytics';
|
|
27
|
+
|
|
28
|
+
const vocabulary = {
|
|
29
|
+
pages: { '/': 'Site', '/privacy': 'Privacy' },
|
|
30
|
+
events: { 'calculation.solve': ['calculation', 'result', 'duration_ms'] },
|
|
31
|
+
enums: {
|
|
32
|
+
calculation: new Set(['dcopf', 'acpf']),
|
|
33
|
+
result: new Set(['completed', 'failed', 'cancelled'])
|
|
34
|
+
},
|
|
35
|
+
numbers: new Set(['duration_ms'])
|
|
36
|
+
};
|
|
37
|
+
if (checkVocabulary(vocabulary).length) throw new Error('fix the vocabulary');
|
|
38
|
+
|
|
39
|
+
const analytics = createAnalytics({
|
|
40
|
+
...vocabulary,
|
|
41
|
+
website: 'your-umami-website-id',
|
|
42
|
+
hosts: ['example.org', 'www.example.org'],
|
|
43
|
+
optOut: localStorageOptOut('umami.disabled')
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
analytics.page(location.pathname);
|
|
47
|
+
analytics.track('calculation.solve', {
|
|
48
|
+
calculation: 'dcopf',
|
|
49
|
+
result: 'completed',
|
|
50
|
+
duration_ms: 812
|
|
51
|
+
});
|
|
52
|
+
analytics.setOptOut(true); // a privacy page's switch
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`eventCredits(vocabulary, name)` gives what one occurrence costs against an
|
|
56
|
+
Umami quota (the event plus each stored property), so a site can pin its
|
|
57
|
+
budget in a test. `sanitize` returns the exact payload an event would send,
|
|
58
|
+
for tests that assert nothing private is in it.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Sender, type Payload } from './sender.js';
|
|
2
|
+
import { type CleanEventData, type EventData, type Vocabulary } from './vocabulary.js';
|
|
3
|
+
/** Where the visitor's choice to turn analytics off is kept. */
|
|
4
|
+
export interface OptOutStore {
|
|
5
|
+
get(): boolean;
|
|
6
|
+
set(disabled: boolean): void;
|
|
7
|
+
}
|
|
8
|
+
type StorageLike = {
|
|
9
|
+
getItem(key: string): string | null;
|
|
10
|
+
setItem(key: string, value: string): void;
|
|
11
|
+
removeItem(key: string): void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* An opt-out kept in `localStorage` under `key` (Umami's own tracker honours
|
|
15
|
+
* `umami.disabled`). Storage that cannot be read or written counts as opted
|
|
16
|
+
* out: a choice that cannot be kept is not asked for.
|
|
17
|
+
*/
|
|
18
|
+
export declare function localStorageOptOut(key: string, win?: {
|
|
19
|
+
localStorage: StorageLike;
|
|
20
|
+
}): OptOutStore;
|
|
21
|
+
export interface AnalyticsConfig extends Vocabulary {
|
|
22
|
+
/** Umami website id. */
|
|
23
|
+
website: string;
|
|
24
|
+
/** Hostnames that report; every other host (previews, local servers) sends nothing. */
|
|
25
|
+
hosts: readonly string[];
|
|
26
|
+
/** Collection endpoint; defaults to Umami Cloud's. */
|
|
27
|
+
endpoint?: string;
|
|
28
|
+
/** Replaces the default sender (tests). */
|
|
29
|
+
sender?: Sender;
|
|
30
|
+
/** Where the opt-out is kept; defaults to nothing kept, so the choice lasts the page. */
|
|
31
|
+
optOut?: OptOutStore;
|
|
32
|
+
/** The page's window; defaults to the global one. */
|
|
33
|
+
window?: Window;
|
|
34
|
+
/** Ceiling for measurements, default one day in milliseconds. */
|
|
35
|
+
maxNumber?: number;
|
|
36
|
+
/** A site rule applied after sanitising, such as dropping a property that only makes sense with another. */
|
|
37
|
+
refine?: (name: string, data: CleanEventData) => CleanEventData;
|
|
38
|
+
}
|
|
39
|
+
export interface AnalyticsClient {
|
|
40
|
+
/** Whether anything may be sent right now. */
|
|
41
|
+
readonly allowed: boolean;
|
|
42
|
+
/** Do Not Track or Global Privacy Control is on in this browser. */
|
|
43
|
+
readonly blockedByBrowser: boolean;
|
|
44
|
+
/** The visitor turned analytics off, or their choice cannot be kept. */
|
|
45
|
+
readonly optedOut: boolean;
|
|
46
|
+
/** Report a view of a listed page. */
|
|
47
|
+
page(path: string): void;
|
|
48
|
+
/** Report a listed event with sanitised data. Unlisted names are ignored. */
|
|
49
|
+
track(name: string, data?: EventData): void;
|
|
50
|
+
/** Turn analytics off or on for this browser. */
|
|
51
|
+
setOptOut(disabled: boolean): void;
|
|
52
|
+
/** Be told when the opt-out changes. */
|
|
53
|
+
subscribe(listener: () => void): () => void;
|
|
54
|
+
/** The payload an event or page view would send, or null when nothing may be sent. */
|
|
55
|
+
sanitize(name: string | undefined, data: Record<string, unknown>, path: string): Payload | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A client that sends only what the vocabulary lists, only from the listed
|
|
59
|
+
* hosts over https, never under Do Not Track or Global Privacy Control, and
|
|
60
|
+
* never after the visitor opted out. It stores nothing itself: the opt-out
|
|
61
|
+
* store is the only thing written, and only when the visitor asks.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createAnalytics(config: AnalyticsConfig): AnalyticsClient;
|
|
64
|
+
export {};
|
|
65
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAE,KAAK,OAAO,EAAe,MAAM,aAAa,CAAC;AACrE,OAAO,EACN,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,UAAU,EAIf,MAAM,iBAAiB,CAAC;AAEzB,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC3B,GAAG,IAAI,OAAO,CAAC;IACf,GAAG,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAC;CAC7B;AAED,KAAK,WAAW,GAAG;IAClB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE;IAAE,YAAY,EAAE,WAAW,CAAA;CAAE,GAAG,WAAW,CA6BhG;AAED,MAAM,WAAW,eAAgB,SAAQ,UAAU;IAClD,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yFAAyF;IACzF,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iEAAiE;IACjE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4GAA4G;IAC5G,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,KAAK,cAAc,CAAC;CAChE;AAED,MAAM,WAAW,eAAe;IAC/B,8CAA8C;IAC9C,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,sCAAsC;IACtC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,6EAA6E;IAC7E,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC5C,iDAAiD;IACjD,SAAS,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAC;IACnC,wCAAwC;IACxC,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5C,sFAAsF;IACtF,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;CAChG;AAID;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,eAAe,CA2FxE"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { umamiSender } from './sender.js';
|
|
2
|
+
import { DEFAULT_MAX_NUMBER, eventData, knownPage } from './vocabulary.js';
|
|
3
|
+
/**
|
|
4
|
+
* An opt-out kept in `localStorage` under `key` (Umami's own tracker honours
|
|
5
|
+
* `umami.disabled`). Storage that cannot be read or written counts as opted
|
|
6
|
+
* out: a choice that cannot be kept is not asked for.
|
|
7
|
+
*/
|
|
8
|
+
export function localStorageOptOut(key, win) {
|
|
9
|
+
const storage = () => {
|
|
10
|
+
try {
|
|
11
|
+
const host = win ?? (typeof window === 'undefined' ? undefined : window);
|
|
12
|
+
return host?.localStorage ?? null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
get() {
|
|
20
|
+
try {
|
|
21
|
+
const store = storage();
|
|
22
|
+
return store === null || store.getItem(key) !== null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
set(disabled) {
|
|
29
|
+
try {
|
|
30
|
+
const store = storage();
|
|
31
|
+
if (!store)
|
|
32
|
+
return;
|
|
33
|
+
if (disabled)
|
|
34
|
+
store.setItem(key, '1');
|
|
35
|
+
else
|
|
36
|
+
store.removeItem(key);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* Storage denied: the in-memory flag in the client still applies. */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A client that sends only what the vocabulary lists, only from the listed
|
|
46
|
+
* hosts over https, never under Do Not Track or Global Privacy Control, and
|
|
47
|
+
* never after the visitor opted out. It stores nothing itself: the opt-out
|
|
48
|
+
* store is the only thing written, and only when the visitor asks.
|
|
49
|
+
*/
|
|
50
|
+
export function createAnalytics(config) {
|
|
51
|
+
const win = config.window ?? (typeof window === 'undefined' ? undefined : window);
|
|
52
|
+
const hosts = new Set(config.hosts);
|
|
53
|
+
const sender = config.sender ?? umamiSender(config.endpoint);
|
|
54
|
+
const maxNumber = config.maxNumber ?? DEFAULT_MAX_NUMBER;
|
|
55
|
+
const listeners = new Set();
|
|
56
|
+
let disabled = false;
|
|
57
|
+
const client = {
|
|
58
|
+
get blockedByBrowser() {
|
|
59
|
+
if (!win)
|
|
60
|
+
return true;
|
|
61
|
+
try {
|
|
62
|
+
const nav = win.navigator;
|
|
63
|
+
const signal = nav.doNotTrack ?? nav.msDoNotTrack ?? '';
|
|
64
|
+
return signal === '1' || signal === 'yes' || nav.globalPrivacyControl === true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
get optedOut() {
|
|
71
|
+
if (disabled)
|
|
72
|
+
return true;
|
|
73
|
+
try {
|
|
74
|
+
return config.optOut ? config.optOut.get() : false;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
get allowed() {
|
|
81
|
+
if (!win)
|
|
82
|
+
return false;
|
|
83
|
+
try {
|
|
84
|
+
return (win.location.protocol === 'https:' &&
|
|
85
|
+
hosts.has(win.location.hostname) &&
|
|
86
|
+
!client.blockedByBrowser &&
|
|
87
|
+
!client.optedOut);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
sanitize(name, data, path) {
|
|
94
|
+
if (!client.allowed || !win)
|
|
95
|
+
return null;
|
|
96
|
+
const url = knownPage(config.pages, path);
|
|
97
|
+
const payload = {
|
|
98
|
+
website: config.website,
|
|
99
|
+
hostname: win.location.hostname,
|
|
100
|
+
url,
|
|
101
|
+
title: config.pages[url] ?? '',
|
|
102
|
+
referrer: ''
|
|
103
|
+
};
|
|
104
|
+
if (name === undefined)
|
|
105
|
+
return payload;
|
|
106
|
+
if (!(name in config.events))
|
|
107
|
+
return null;
|
|
108
|
+
payload.name = name;
|
|
109
|
+
let clean = eventData(config, name, data, maxNumber);
|
|
110
|
+
if (config.refine)
|
|
111
|
+
clean = config.refine(name, clean);
|
|
112
|
+
payload.data = clean;
|
|
113
|
+
return payload;
|
|
114
|
+
},
|
|
115
|
+
page(path) {
|
|
116
|
+
try {
|
|
117
|
+
const payload = client.sanitize(undefined, {}, path);
|
|
118
|
+
if (payload)
|
|
119
|
+
sender.send(payload);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* Measurement never interrupts the page. */
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
track(name, data = {}) {
|
|
126
|
+
try {
|
|
127
|
+
const payload = client.sanitize(name, data, win?.location.pathname ?? '/');
|
|
128
|
+
if (payload)
|
|
129
|
+
sender.send(payload);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* Measurement never interrupts the page. */
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
setOptOut(next) {
|
|
136
|
+
disabled = next;
|
|
137
|
+
try {
|
|
138
|
+
config.optOut?.set(next);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
disabled = true;
|
|
142
|
+
}
|
|
143
|
+
for (const listener of listeners)
|
|
144
|
+
listener();
|
|
145
|
+
},
|
|
146
|
+
subscribe(listener) {
|
|
147
|
+
listeners.add(listener);
|
|
148
|
+
return () => {
|
|
149
|
+
listeners.delete(listener);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
return client;
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { checkVocabulary, eventCredits, eventData, knownPage, DEFAULT_MAX_NUMBER, EVENT_NAME_LIMIT, PROPERTY_LIMIT, STRING_LIMIT } from './vocabulary.js';
|
|
2
|
+
export type { CleanEventData, EventData, Vocabulary } from './vocabulary.js';
|
|
3
|
+
export { umamiSender, UMAMI_SEND } from './sender.js';
|
|
4
|
+
export type { FetchLike, Payload, Sender } from './sender.js';
|
|
5
|
+
export { createAnalytics, localStorageOptOut } from './client.js';
|
|
6
|
+
export type { AnalyticsClient, AnalyticsConfig, OptOutStore } from './client.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,eAAe,EACf,YAAY,EACZ,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7E,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACtD,YAAY,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { checkVocabulary, eventCredits, eventData, knownPage, DEFAULT_MAX_NUMBER, EVENT_NAME_LIMIT, PROPERTY_LIMIT, STRING_LIMIT } from './vocabulary.js';
|
|
2
|
+
export { umamiSender, UMAMI_SEND } from './sender.js';
|
|
3
|
+
export { createAnalytics, localStorageOptOut } from './client.js';
|
|
4
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,eAAe,EACf,YAAY,EACZ,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/sender.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one request this package makes: a POST of a sanitised payload to Umami
|
|
3
|
+
* Cloud's collection endpoint, the same request Umami's own tracker script
|
|
4
|
+
* sends. No script is loaded from a third party. The address is one literal,
|
|
5
|
+
* never assembled, so a site's content security policy can name its origin.
|
|
6
|
+
*/
|
|
7
|
+
export declare const UMAMI_SEND = "https://gateway.umami.is/api/send";
|
|
8
|
+
/** What an event or page view carries. Nothing else is ever sent. */
|
|
9
|
+
export interface Payload {
|
|
10
|
+
/** Umami website id. */
|
|
11
|
+
website: string;
|
|
12
|
+
/** The page's hostname. */
|
|
13
|
+
hostname: string;
|
|
14
|
+
/** A listed page path, never a query or a fragment. */
|
|
15
|
+
url: string;
|
|
16
|
+
/** That page's listed title. */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Always empty: referring addresses are not reported. */
|
|
19
|
+
referrer: '';
|
|
20
|
+
/** The event name, absent for a page view. */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Sanitised event data. */
|
|
23
|
+
data?: Record<string, string | number>;
|
|
24
|
+
}
|
|
25
|
+
export interface Sender {
|
|
26
|
+
send(payload: Payload): void;
|
|
27
|
+
}
|
|
28
|
+
/** The subset of `fetch` the sender needs; a test passes a stub. */
|
|
29
|
+
export type FetchLike = (input: string, init: {
|
|
30
|
+
method: 'POST';
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
body: string;
|
|
33
|
+
keepalive: boolean;
|
|
34
|
+
credentials: 'omit';
|
|
35
|
+
referrerPolicy: 'no-referrer';
|
|
36
|
+
mode: 'cors';
|
|
37
|
+
}) => Promise<{
|
|
38
|
+
ok: boolean;
|
|
39
|
+
json(): Promise<unknown>;
|
|
40
|
+
} | undefined | void> | void;
|
|
41
|
+
/**
|
|
42
|
+
* A sender for Umami's collection endpoint. The response carries a `cache`
|
|
43
|
+
* token that lets the server skip recomputing the visitor's session; it is
|
|
44
|
+
* kept in memory only and resent as the `x-umami-cache` header. Every
|
|
45
|
+
* failure is swallowed: analytics never changes what the page does.
|
|
46
|
+
*/
|
|
47
|
+
export declare function umamiSender(endpoint?: string, fetchFn?: FetchLike): Sender;
|
|
48
|
+
//# sourceMappingURL=sender.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender.d.ts","sourceRoot":"","sources":["../src/sender.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,UAAU,sCAAsC,CAAC;AAE9D,qEAAqE;AACrE,MAAM,WAAW,OAAO;IACvB,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,QAAQ,EAAE,EAAE,CAAC;IACb,8CAA8C;IAC9C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,MAAM;IACtB,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;CAC7B;AAED,oEAAoE;AACpE,MAAM,MAAM,SAAS,GAAG,CACvB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE;IACL,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,aAAa,CAAC;IAC9B,IAAI,EAAE,MAAM,CAAC;CACb,KACG,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,GAAG,SAAS,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;AAElF;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,QAAQ,GAAE,MAAmB,EAAE,OAAO,CAAC,EAAE,SAAS,GAAG,MAAM,CAoCtF"}
|
package/dist/sender.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one request this package makes: a POST of a sanitised payload to Umami
|
|
3
|
+
* Cloud's collection endpoint, the same request Umami's own tracker script
|
|
4
|
+
* sends. No script is loaded from a third party. The address is one literal,
|
|
5
|
+
* never assembled, so a site's content security policy can name its origin.
|
|
6
|
+
*/
|
|
7
|
+
export const UMAMI_SEND = 'https://gateway.umami.is/api/send';
|
|
8
|
+
/**
|
|
9
|
+
* A sender for Umami's collection endpoint. The response carries a `cache`
|
|
10
|
+
* token that lets the server skip recomputing the visitor's session; it is
|
|
11
|
+
* kept in memory only and resent as the `x-umami-cache` header. Every
|
|
12
|
+
* failure is swallowed: analytics never changes what the page does.
|
|
13
|
+
*/
|
|
14
|
+
export function umamiSender(endpoint = UMAMI_SEND, fetchFn) {
|
|
15
|
+
let cache;
|
|
16
|
+
return {
|
|
17
|
+
send(payload) {
|
|
18
|
+
try {
|
|
19
|
+
const call = fetchFn ?? (typeof fetch === 'function' ? fetch : undefined);
|
|
20
|
+
if (!call)
|
|
21
|
+
return;
|
|
22
|
+
const headers = { 'content-type': 'application/json' };
|
|
23
|
+
if (cache)
|
|
24
|
+
headers['x-umami-cache'] = cache;
|
|
25
|
+
const request = call(endpoint, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers,
|
|
28
|
+
body: JSON.stringify({ type: 'event', payload }),
|
|
29
|
+
keepalive: true,
|
|
30
|
+
credentials: 'omit',
|
|
31
|
+
referrerPolicy: 'no-referrer',
|
|
32
|
+
mode: 'cors'
|
|
33
|
+
});
|
|
34
|
+
Promise.resolve(request)
|
|
35
|
+
.then(async (response) => {
|
|
36
|
+
if (!response || !response.ok)
|
|
37
|
+
return;
|
|
38
|
+
const body = await response.json();
|
|
39
|
+
if (body &&
|
|
40
|
+
typeof body === 'object' &&
|
|
41
|
+
typeof body.cache === 'string')
|
|
42
|
+
cache = body.cache;
|
|
43
|
+
})
|
|
44
|
+
.catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* A blocked or failing collector must not reach the page. */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=sender.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender.js","sourceRoot":"","sources":["../src/sender.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,mCAAmC,CAAC;AAsC9D;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,QAAQ,GAAW,UAAU,EAAE,OAAmB;IAC7E,IAAI,KAAyB,CAAC;IAC9B,OAAO;QACN,IAAI,CAAC,OAAO;YACX,IAAI,CAAC;gBACJ,MAAM,IAAI,GACT,OAAO,IAAI,CAAC,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAE,KAA8B,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;gBACxF,IAAI,CAAC,IAAI;oBAAE,OAAO;gBAClB,MAAM,OAAO,GAA2B,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC;gBAC/E,IAAI,KAAK;oBAAE,OAAO,CAAC,eAAe,CAAC,GAAG,KAAK,CAAC;gBAC5C,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,EAAE;oBAC9B,MAAM,EAAE,MAAM;oBACd,OAAO;oBACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;oBAChD,SAAS,EAAE,IAAI;oBACf,WAAW,EAAE,MAAM;oBACnB,cAAc,EAAE,aAAa;oBAC7B,IAAI,EAAE,MAAM;iBACZ,CAAC,CAAC;gBACH,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC;qBACtB,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE;oBACxB,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ,CAAC,EAAE;wBAAE,OAAO;oBACtC,MAAM,IAAI,GAAY,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;oBAC5C,IACC,IAAI;wBACJ,OAAO,IAAI,KAAK,QAAQ;wBACxB,OAAQ,IAA4B,CAAC,KAAK,KAAK,QAAQ;wBAEvD,KAAK,GAAI,IAA0B,CAAC,KAAK,CAAC;gBAC5C,CAAC,CAAC;qBACD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;YACnB,CAAC;YAAC,MAAM,CAAC;gBACR,6DAA6D;YAC9D,CAAC;QACF,CAAC;KACD,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A site's analytics vocabulary: the only page paths, event names, property
|
|
3
|
+
* keys and property values that may ever leave the browser. Every outgoing
|
|
4
|
+
* payload is rebuilt from it, so a value that is not listed here is dropped
|
|
5
|
+
* before it is sent.
|
|
6
|
+
*/
|
|
7
|
+
export interface Vocabulary {
|
|
8
|
+
/** Page paths that may be reported, each with its title. Any other path is reported as the root. */
|
|
9
|
+
pages: Record<string, string>;
|
|
10
|
+
/** Event names and the property keys each may carry. */
|
|
11
|
+
events: Record<string, readonly string[]>;
|
|
12
|
+
/** The values each string property may take. */
|
|
13
|
+
enums: Record<string, ReadonlySet<string>>;
|
|
14
|
+
/** Properties that carry a non-negative integer measurement. */
|
|
15
|
+
numbers: ReadonlySet<string>;
|
|
16
|
+
}
|
|
17
|
+
/** What a caller passes to `track`: anything, since only listed keys survive. */
|
|
18
|
+
export type EventData = Record<string, string | number | undefined>;
|
|
19
|
+
/** Event data after sanitising: listed enum values and clamped integers only. */
|
|
20
|
+
export type CleanEventData = Record<string, string | number>;
|
|
21
|
+
/** Umami's limits on what it stores (docs.umami.is, tracker functions). */
|
|
22
|
+
export declare const EVENT_NAME_LIMIT = 50;
|
|
23
|
+
export declare const STRING_LIMIT = 500;
|
|
24
|
+
export declare const PROPERTY_LIMIT = 50;
|
|
25
|
+
/** Default ceiling for measurements: one day in milliseconds. */
|
|
26
|
+
export declare const DEFAULT_MAX_NUMBER = 86400000;
|
|
27
|
+
/** The listed page for a URL or path, or the root when the path is not listed. */
|
|
28
|
+
export declare function knownPage(pages: Vocabulary['pages'], value: unknown): string;
|
|
29
|
+
/**
|
|
30
|
+
* The event data that may be sent for an event: listed keys only, string
|
|
31
|
+
* values that the property's enum lists, numbers clamped to non-negative
|
|
32
|
+
* integers no larger than `maxNumber`. Unlisted keys and values vanish.
|
|
33
|
+
*/
|
|
34
|
+
export declare function eventData(vocabulary: Vocabulary, name: string, input: Record<string, unknown>, maxNumber?: number): CleanEventData;
|
|
35
|
+
/**
|
|
36
|
+
* How many units of an Umami quota one occurrence of the event costs: the
|
|
37
|
+
* event itself plus each stored property.
|
|
38
|
+
*/
|
|
39
|
+
export declare function eventCredits(vocabulary: Vocabulary, name: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Problems with a vocabulary, as sentences; an empty list means every name
|
|
42
|
+
* and value fits Umami's limits and every property is either an enum or a
|
|
43
|
+
* number. Sites assert this in a unit test so a vocabulary change is checked
|
|
44
|
+
* before it ships.
|
|
45
|
+
*/
|
|
46
|
+
export declare function checkVocabulary(vocabulary: Vocabulary): string[];
|
|
47
|
+
//# sourceMappingURL=vocabulary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vocabulary.d.ts","sourceRoot":"","sources":["../src/vocabulary.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IAC1B,oGAAoG;IACpG,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IAC1C,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;IAC3C,gEAAgE;IAChE,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAC7B;AAED,iFAAiF;AACjF,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;AAEpE,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;AAE7D,2EAA2E;AAC3E,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,YAAY,MAAM,CAAC;AAChC,eAAO,MAAM,cAAc,KAAK,CAAC;AACjC,iEAAiE;AACjE,eAAO,MAAM,kBAAkB,WAAa,CAAC;AAE7C,kFAAkF;AAClF,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAS5E;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACxB,UAAU,EAAE,UAAU,EACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,SAAS,SAAqB,GAC5B,cAAc,CAgBhB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAIzE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,UAAU,GAAG,MAAM,EAAE,CA+BhE"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Umami's limits on what it stores (docs.umami.is, tracker functions). */
|
|
2
|
+
export const EVENT_NAME_LIMIT = 50;
|
|
3
|
+
export const STRING_LIMIT = 500;
|
|
4
|
+
export const PROPERTY_LIMIT = 50;
|
|
5
|
+
/** Default ceiling for measurements: one day in milliseconds. */
|
|
6
|
+
export const DEFAULT_MAX_NUMBER = 86_400_000;
|
|
7
|
+
/** The listed page for a URL or path, or the root when the path is not listed. */
|
|
8
|
+
export function knownPage(pages, value) {
|
|
9
|
+
const root = '/' in pages ? '/' : (Object.keys(pages)[0] ?? '/');
|
|
10
|
+
if (typeof value !== 'string')
|
|
11
|
+
return root;
|
|
12
|
+
try {
|
|
13
|
+
const path = new URL(value, 'https://vocabulary.invalid').pathname;
|
|
14
|
+
return path in pages ? path : root;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The event data that may be sent for an event: listed keys only, string
|
|
22
|
+
* values that the property's enum lists, numbers clamped to non-negative
|
|
23
|
+
* integers no larger than `maxNumber`. Unlisted keys and values vanish.
|
|
24
|
+
*/
|
|
25
|
+
export function eventData(vocabulary, name, input, maxNumber = DEFAULT_MAX_NUMBER) {
|
|
26
|
+
const output = {};
|
|
27
|
+
for (const key of vocabulary.events[name] ?? []) {
|
|
28
|
+
const value = input[key];
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
if (vocabulary.enums[key]?.has(value))
|
|
31
|
+
output[key] = value;
|
|
32
|
+
}
|
|
33
|
+
else if (vocabulary.numbers.has(key) &&
|
|
34
|
+
typeof value === 'number' &&
|
|
35
|
+
Number.isFinite(value) &&
|
|
36
|
+
value >= 0) {
|
|
37
|
+
output[key] = Math.min(maxNumber, Math.round(value));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* How many units of an Umami quota one occurrence of the event costs: the
|
|
44
|
+
* event itself plus each stored property.
|
|
45
|
+
*/
|
|
46
|
+
export function eventCredits(vocabulary, name) {
|
|
47
|
+
const properties = vocabulary.events[name];
|
|
48
|
+
if (!properties)
|
|
49
|
+
return 0;
|
|
50
|
+
return 1 + properties.length;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Problems with a vocabulary, as sentences; an empty list means every name
|
|
54
|
+
* and value fits Umami's limits and every property is either an enum or a
|
|
55
|
+
* number. Sites assert this in a unit test so a vocabulary change is checked
|
|
56
|
+
* before it ships.
|
|
57
|
+
*/
|
|
58
|
+
export function checkVocabulary(vocabulary) {
|
|
59
|
+
const problems = [];
|
|
60
|
+
const pageEntries = Object.entries(vocabulary.pages);
|
|
61
|
+
if (pageEntries.length === 0)
|
|
62
|
+
problems.push('no pages are listed');
|
|
63
|
+
for (const [path, title] of pageEntries) {
|
|
64
|
+
if (!path.startsWith('/'))
|
|
65
|
+
problems.push(`page ${path} is not an absolute path`);
|
|
66
|
+
if (title.length === 0 || title.length > STRING_LIMIT)
|
|
67
|
+
problems.push(`page ${path} has a bad title`);
|
|
68
|
+
}
|
|
69
|
+
for (const [name, properties] of Object.entries(vocabulary.events)) {
|
|
70
|
+
if (name.length === 0 || name.length > EVENT_NAME_LIMIT)
|
|
71
|
+
problems.push(`event ${name} has a name longer than ${EVENT_NAME_LIMIT}`);
|
|
72
|
+
if (properties.length > PROPERTY_LIMIT)
|
|
73
|
+
problems.push(`event ${name} has more than ${PROPERTY_LIMIT} properties`);
|
|
74
|
+
if (new Set(properties).size !== properties.length)
|
|
75
|
+
problems.push(`event ${name} lists a property twice`);
|
|
76
|
+
for (const key of properties) {
|
|
77
|
+
const isEnum = key in vocabulary.enums;
|
|
78
|
+
const isNumber = vocabulary.numbers.has(key);
|
|
79
|
+
if (isEnum === isNumber)
|
|
80
|
+
problems.push(`property ${key} of ${name} must be exactly one of an enum or a number`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const [key, values] of Object.entries(vocabulary.enums)) {
|
|
84
|
+
if (values.size === 0)
|
|
85
|
+
problems.push(`enum ${key} has no values`);
|
|
86
|
+
for (const value of values) {
|
|
87
|
+
if (value.length === 0 || value.length > STRING_LIMIT)
|
|
88
|
+
problems.push(`enum ${key} has a value outside 1 to ${STRING_LIMIT} characters`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return problems;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=vocabulary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vocabulary.js","sourceRoot":"","sources":["../src/vocabulary.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@tellegen/analytics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Allowlisted, cookieless usage analytics for Tellegen sites: a vocabulary sanitizer and a first-party sender for Umami Cloud.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"analytics",
|
|
7
|
+
"umami",
|
|
8
|
+
"privacy",
|
|
9
|
+
"browser"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/eigenergy/tellegen.git",
|
|
16
|
+
"directory": "packages/analytics"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/eigenergy/tellegen/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://eigenergy.github.io/tellegen/",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json && npm test",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"pack:dry-run": "npm pack --dry-run --cache ../../node_modules/.cache/npm-pack"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
|
+
"vitest": "^5.1.0"
|
|
45
|
+
}
|
|
46
|
+
}
|