@heyramzi/utils 0.0.0-stage → 1.13.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/dist/ai/image.js +220 -0
  4. package/dist/ai/models.js +261 -0
  5. package/dist/analytics/client.js +57 -0
  6. package/dist/analytics/server.js +54 -0
  7. package/dist/analytics/svelte.js +24 -0
  8. package/dist/analytics/types.js +0 -0
  9. package/dist/analytics/umami/protocol.js +35 -0
  10. package/dist/analytics/umami/reporting.js +189 -0
  11. package/dist/auth/config/otp.js +4 -0
  12. package/dist/auth/config/rate-limits.js +20 -0
  13. package/dist/auth/config/trusted-origins.js +15 -0
  14. package/dist/auth/email/index.js +3 -0
  15. package/dist/auth/email/layout.js +84 -0
  16. package/dist/auth/email/resend.js +30 -0
  17. package/dist/auth/email/template.js +57 -0
  18. package/dist/auth/email/types.js +0 -0
  19. package/dist/auth/index.js +3 -0
  20. package/dist/cache.js +4 -0
  21. package/dist/cn.js +5 -0
  22. package/dist/collection.js +8 -0
  23. package/dist/crypto.js +117 -0
  24. package/dist/error.js +15 -0
  25. package/dist/errors/sentry/index.js +15 -0
  26. package/dist/errors/sentry/protocol.js +130 -0
  27. package/dist/errors/sentry/sveltekit.js +145 -0
  28. package/dist/format.js +62 -0
  29. package/dist/grab.js +112 -0
  30. package/dist/index.js +10 -0
  31. package/dist/logger-types.js +0 -0
  32. package/dist/logger.js +22 -0
  33. package/dist/math.js +3 -0
  34. package/dist/og/fonts.js +46 -0
  35. package/dist/og/index.js +3 -0
  36. package/dist/og/markup.js +31 -0
  37. package/dist/og/renderer.js +29 -0
  38. package/dist/og/types.js +0 -0
  39. package/dist/result.js +26 -0
  40. package/dist/string.js +32 -0
  41. package/dist/testimonials/render-content.js +15 -0
  42. package/dist/testimonials/server.js +21 -0
  43. package/dist/testimonials/types.js +0 -0
  44. package/dist/timing.js +20 -0
  45. package/dist/vite.js +265 -0
  46. package/package.json +201 -4
  47. package/src/ai/image-agnes.test.ts +130 -0
  48. package/src/ai/image.ts +435 -0
  49. package/src/ai/models.test.ts +98 -0
  50. package/src/ai/models.ts +450 -0
  51. package/src/analytics/client.test.ts +149 -0
  52. package/src/analytics/client.ts +129 -0
  53. package/src/analytics/server.test.ts +165 -0
  54. package/src/analytics/server.ts +88 -0
  55. package/src/analytics/svelte.test.ts +105 -0
  56. package/src/analytics/svelte.ts +58 -0
  57. package/src/analytics/types.ts +26 -0
  58. package/src/analytics/umami/protocol.test.ts +18 -0
  59. package/src/analytics/umami/protocol.ts +104 -0
  60. package/src/analytics/umami/reporting.test.ts +391 -0
  61. package/src/analytics/umami/reporting.ts +627 -0
  62. package/src/auth/config/otp.ts +8 -0
  63. package/src/auth/config/rate-limits.ts +11 -0
  64. package/src/auth/config/trusted-origins.ts +32 -0
  65. package/src/auth/email/index.ts +7 -0
  66. package/src/auth/email/layout.test.ts +73 -0
  67. package/src/auth/email/layout.ts +122 -0
  68. package/src/auth/email/resend.test.ts +35 -0
  69. package/src/auth/email/resend.ts +26 -0
  70. package/src/auth/email/template.ts +77 -0
  71. package/src/auth/email/types.ts +74 -0
  72. package/src/auth/index.ts +5 -0
  73. package/src/cache.test.ts +67 -0
  74. package/src/cache.ts +5 -0
  75. package/src/cn.test.ts +52 -0
  76. package/src/cn.ts +16 -0
  77. package/src/collection.test.ts +66 -0
  78. package/src/collection.ts +8 -0
  79. package/src/crypto.test.ts +86 -0
  80. package/src/crypto.ts +157 -0
  81. package/src/error.test.ts +79 -0
  82. package/src/error.ts +16 -0
  83. package/src/errors/sentry/index.ts +20 -0
  84. package/src/errors/sentry/protocol.test.ts +85 -0
  85. package/src/errors/sentry/protocol.ts +144 -0
  86. package/src/errors/sentry/sveltekit.test.ts +149 -0
  87. package/src/errors/sentry/sveltekit.ts +226 -0
  88. package/src/format.test.ts +225 -0
  89. package/src/format.ts +103 -0
  90. package/src/grab.d.ts +2 -0
  91. package/src/grab.js +112 -0
  92. package/src/index.ts +38 -0
  93. package/src/logger-types.ts +14 -0
  94. package/src/logger.ts +24 -0
  95. package/src/math.test.ts +41 -0
  96. package/src/math.ts +3 -0
  97. package/src/og/fonts.ts +69 -0
  98. package/src/og/index.ts +5 -0
  99. package/src/og/markup.test.ts +73 -0
  100. package/src/og/markup.ts +90 -0
  101. package/src/og/renderer.ts +42 -0
  102. package/src/og/types.ts +26 -0
  103. package/src/og/wawoff2.d.ts +6 -0
  104. package/src/package-imports.test.mjs +22 -0
  105. package/src/result.test.ts +149 -0
  106. package/src/result.ts +29 -0
  107. package/src/string.test.ts +205 -0
  108. package/src/string.ts +71 -0
  109. package/src/testimonials/render-content.ts +21 -0
  110. package/src/testimonials/server.ts +47 -0
  111. package/src/testimonials/types.ts +35 -0
  112. package/src/timing.test.ts +166 -0
  113. package/src/timing.ts +28 -0
  114. package/src/vite.d.ts +12 -0
  115. package/src/vite.js +265 -0
  116. package/src/vite.test.js +235 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Umami in the browser: one init call, then track, trackRevenue and setProfileId.
3
+ * Nothing fires before `initAnalytics` or after its cleanup.
4
+ */
5
+
6
+ /*
7
+ * initAnalytics({ websiteId: env.PUBLIC_UMAMI_WEBSITE_ID, host: env.PUBLIC_UMAMI_HOST, disabled: import.meta.env.DEV });
8
+ */
9
+
10
+ import "#src/analytics/types.js";
11
+
12
+ export type EventData = Record<string, string | number | boolean | null | undefined>;
13
+
14
+ export type RevenueArgs = {
15
+ /** purchase_completed | subscription_started | subscription_renewed */
16
+ event: string;
17
+ /** In dollars (not cents). */
18
+ value: number;
19
+ /** ISO 4217 (e.g. USD, EUR). */
20
+ currency: string;
21
+ /** Plan name when relevant. */
22
+ plan?: string;
23
+ /** Stripe id for backend dedupe. */
24
+ transactionId?: string;
25
+ };
26
+
27
+ export type UmamiOptions = {
28
+ /** Umami website ID. */
29
+ websiteId: string;
30
+ /** Umami host without trailing slash. */
31
+ host: string;
32
+ /**
33
+ * Inject the Umami script tag from the adapter. Default true. Set false if
34
+ * the script is already in `app.html` or otherwise loaded.
35
+ */
36
+ autoInjectScript?: boolean;
37
+ /** Collect Core Web Vitals (LCP, INP, CLS, FCP, TTFB). Umami v3.1+. Default false. */
38
+ performance?: boolean;
39
+ /** Group events under a named tag for filtering. Umami v2.11+. */
40
+ tag?: string;
41
+ /** Window function name to inspect/modify payloads before sending. Umami v2.18+. */
42
+ beforeSend?: string;
43
+ /** Restrict tracking to listed hostnames. Umami v2.0+. */
44
+ domains?: string[];
45
+ /** Honor the browser's Do-Not-Track signal. Umami v2.17+. Default false. */
46
+ doNotTrack?: boolean;
47
+ /** Disable automatic pageview/event tracking and call `umami.track()` manually. Default true. */
48
+ autoTrack?: boolean;
49
+ /** Strip URL search params from tracked URLs. Umami v2.11+. Default false. */
50
+ excludeSearch?: boolean;
51
+ /** Strip URL hash from tracked URLs. Umami v2.16+. Default false. */
52
+ excludeHash?: boolean;
53
+ };
54
+
55
+ export type AnalyticsConfig = UmamiOptions & {
56
+ /** Skip tracking entirely (e.g., import.meta.env.DEV). */
57
+ disabled?: boolean;
58
+ };
59
+
60
+ let active = false;
61
+
62
+ /**
63
+ * Start tracking. Returns a cleanup function safe to return directly from a SvelteKit
64
+ * `onMount` callback.
65
+ */
66
+ export function initAnalytics(config: AnalyticsConfig): () => void {
67
+ stop();
68
+ if (config.disabled || typeof window === "undefined") return stop;
69
+
70
+ active = true;
71
+ injectScript(config);
72
+ return stop;
73
+ }
74
+
75
+ function stop(): void {
76
+ active = false;
77
+ }
78
+
79
+ /** Track a custom event. */
80
+ export function track(event: string, meta?: EventData): void {
81
+ if (!active || typeof window === "undefined" || !window.umami) return;
82
+ try {
83
+ if (meta) {
84
+ window.umami.track(event, meta);
85
+ } else {
86
+ window.umami.track(event);
87
+ }
88
+ } catch {
89
+ // WHY: analytics must never break the app
90
+ }
91
+ }
92
+
93
+ /** Fire a revenue event. */
94
+ export function trackRevenue(args: RevenueArgs): void {
95
+ const meta: EventData = { revenue: args.value, currency: args.currency };
96
+ if (args.plan) meta.plan = args.plan;
97
+ if (args.transactionId) meta.transaction_id = args.transactionId;
98
+ track(args.event, meta);
99
+ }
100
+
101
+ /** Set the cookieless profile id. */
102
+ export function setProfileId(id?: string): void {
103
+ if (!active || typeof window === "undefined" || !window.umami?.identify) return;
104
+ try {
105
+ window.umami.identify(id ? { profileId: id } : {});
106
+ } catch {
107
+ // WHY: analytics must never break the app
108
+ }
109
+ }
110
+
111
+ function injectScript(umami: UmamiOptions): void {
112
+ if (typeof document === "undefined") return;
113
+ if (!(umami.autoInjectScript ?? true)) return;
114
+ if (document.querySelector(`script[data-website-id="${umami.websiteId}"]`)) return;
115
+
116
+ const script = document.createElement("script");
117
+ script.defer = true;
118
+ script.src = `${umami.host.replace(/\/+$/, "")}/script.js`;
119
+ script.dataset.websiteId = umami.websiteId;
120
+ if (umami.performance) script.dataset.performance = "true";
121
+ if (umami.tag) script.dataset.tag = umami.tag;
122
+ if (umami.beforeSend) script.dataset.beforeSend = umami.beforeSend;
123
+ if (umami.domains?.length) script.dataset.domains = umami.domains.join(",");
124
+ if (umami.doNotTrack) script.dataset.doNotTrack = "true";
125
+ if (umami.autoTrack === false) script.dataset.autoTrack = "false";
126
+ if (umami.excludeSearch) script.dataset.excludeSearch = "true";
127
+ if (umami.excludeHash) script.dataset.excludeHash = "true";
128
+ document.head.appendChild(script);
129
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
2
+
3
+ // The server module holds its config at module level, so each test re-imports it.
4
+
5
+ async function freshImport() {
6
+ vi.resetModules();
7
+ return import("#src/analytics/server.js");
8
+ }
9
+
10
+ const fetchMock = vi.fn();
11
+
12
+ function answer(body: string, status = 200) {
13
+ fetchMock.mockResolvedValue(new Response(body, { status }));
14
+ }
15
+
16
+ function sent() {
17
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
18
+ return {
19
+ url,
20
+ headers: init.headers as Record<string, string>,
21
+ body: JSON.parse(init.body as string) as { type: string; payload: Record<string, unknown> },
22
+ };
23
+ }
24
+
25
+ beforeEach(() => {
26
+ fetchMock.mockReset();
27
+ answer('{"cache":"x"}');
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ vi.spyOn(console, "error").mockImplementation(() => {});
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ const config = {
38
+ hostUrl: "https://umami.example.com",
39
+ websiteId: "abc-123",
40
+ hostname: "example.com",
41
+ };
42
+
43
+ describe("trackServer", () => {
44
+ it("awaits the POST to /api/send and resolves true", async () => {
45
+ const { initServer, trackServer } = await freshImport();
46
+ initServer(config);
47
+
48
+ await expect(trackServer("signup")).resolves.toBe(true);
49
+
50
+ const { url, headers, body } = sent();
51
+ expect(url).toBe("https://umami.example.com/api/send");
52
+ expect(headers["User-Agent"]).toMatch(/^Mozilla\/5\.0/);
53
+ expect(body).toEqual({
54
+ type: "event",
55
+ payload: { website: "abc-123", hostname: "example.com", url: "/", name: "signup" },
56
+ });
57
+ });
58
+
59
+ it("sends data, url and id", async () => {
60
+ const { initServer, trackServer } = await freshImport();
61
+ initServer(config);
62
+
63
+ await trackServer("click", { label: "CTA" }, { url: "/home", id: "cs_123" });
64
+
65
+ expect(sent().body.payload).toEqual({
66
+ website: "abc-123",
67
+ hostname: "example.com",
68
+ url: "/home",
69
+ name: "click",
70
+ data: { label: "CTA" },
71
+ id: "cs_123",
72
+ });
73
+ });
74
+
75
+ it("still takes the url as a plain string", async () => {
76
+ const { initServer, trackServer } = await freshImport();
77
+ initServer(config);
78
+
79
+ await trackServer("page_view", undefined, "/pricing");
80
+
81
+ expect(sent().body.payload.url).toBe("/pricing");
82
+ expect(sent().body.payload).not.toHaveProperty("id");
83
+ });
84
+
85
+ it("strips a trailing slash from hostUrl and forwards a custom userAgent", async () => {
86
+ const { initServer, trackServer } = await freshImport();
87
+ initServer({
88
+ ...config,
89
+ hostUrl: "https://umami.example.com/",
90
+ userAgent: "Mozilla/5.0 Seam/1.0",
91
+ });
92
+
93
+ await trackServer("ua_test");
94
+
95
+ expect(sent().url).toBe("https://umami.example.com/api/send");
96
+ expect(sent().headers["User-Agent"]).toBe("Mozilla/5.0 Seam/1.0");
97
+ });
98
+
99
+ // Umami rejects an event with HTTP 200 and the body `beep`.
100
+ it("resolves false when Umami answers beep", async () => {
101
+ answer("beep");
102
+ const { initServer, trackServer } = await freshImport();
103
+ initServer(config);
104
+
105
+ await expect(trackServer("dropped")).resolves.toBe(false);
106
+ expect(console.error).toHaveBeenCalled();
107
+ });
108
+
109
+ it("resolves false on an HTTP error", async () => {
110
+ answer("nope", 500);
111
+ const { initServer, trackServer } = await freshImport();
112
+ initServer(config);
113
+
114
+ await expect(trackServer("server_error")).resolves.toBe(false);
115
+ });
116
+
117
+ it("resolves false and never throws when fetch rejects", async () => {
118
+ fetchMock.mockRejectedValue(new Error("network failure"));
119
+ const { initServer, trackServer } = await freshImport();
120
+ initServer(config);
121
+
122
+ await expect(trackServer("risky_event")).resolves.toBe(false);
123
+ });
124
+
125
+ it("resolves false without initServer and sends nothing", async () => {
126
+ const { trackServer } = await freshImport();
127
+
128
+ await expect(trackServer("orphan_event")).resolves.toBe(false);
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe("trackServerRevenue", () => {
134
+ it("merges revenue and currency into data and forwards url and id", async () => {
135
+ const { initServer, trackServerRevenue } = await freshImport();
136
+ initServer(config);
137
+
138
+ await expect(
139
+ trackServerRevenue(
140
+ "Purchase Complete",
141
+ 9.99,
142
+ "EUR",
143
+ { plan: "pro" },
144
+ { url: "/buy", id: "cs_1" },
145
+ ),
146
+ ).resolves.toBe(true);
147
+
148
+ expect(sent().body.payload).toMatchObject({
149
+ name: "Purchase Complete",
150
+ url: "/buy",
151
+ id: "cs_1",
152
+ data: { plan: "pro", revenue: 9.99, currency: "EUR" },
153
+ });
154
+ });
155
+
156
+ it("resolves false when the send fails", async () => {
157
+ answer("beep");
158
+ const { initServer, trackServerRevenue } = await freshImport();
159
+ initServer(config);
160
+
161
+ await expect(
162
+ trackServerRevenue("checkout", 19.99, "USD", undefined, "/checkout"),
163
+ ).resolves.toBe(false);
164
+ });
165
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Server-side Umami tracking. A Worker cancels a fetch still in flight once the response
3
+ * returns, so await each tracker or hand it to `waitUntil`.
4
+ */
5
+
6
+ import type { EventData } from "#src/analytics/types.js";
7
+ import { buildSendBody, UMAMI_ENDPOINTS } from "#src/analytics/umami/protocol.js";
8
+
9
+ type ServerConfig = {
10
+ hostUrl: string;
11
+ websiteId: string;
12
+ /** Site hostname, such as `getseam.app`. Without it Umami logs the event under `localhost`. */
13
+ hostname?: string;
14
+ /** Umami answers a non-browser user agent with `beep` and drops the event. */
15
+ userAgent?: string;
16
+ };
17
+
18
+ /** Per-event options. A string is the url, as in 3.0. */
19
+ export type ServerEventOptions = {
20
+ url?: string;
21
+ /** Umami keys the session on it. Without one, every event shares the Worker's session. */
22
+ id?: string;
23
+ };
24
+
25
+ // Umami's bot filter answers `beep` to any agent containing "Server" (tested 19 Oct 2026).
26
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 Marketing/1.0";
27
+ const TIMEOUT_MS = 3000;
28
+
29
+ let _config: ServerConfig | null = null;
30
+
31
+ /** Initialize the server-side Umami client. Call once at startup. */
32
+ export function initServer(config: ServerConfig): void {
33
+ _config = config;
34
+ }
35
+
36
+ /**
37
+ * Sends one event and checks Umami recorded it. Never throws.
38
+ * Resolves false on a timeout, an HTTP error or a `beep` rejection.
39
+ */
40
+ export async function trackServer(
41
+ name: string,
42
+ data?: EventData,
43
+ options?: string | ServerEventOptions,
44
+ ): Promise<boolean> {
45
+ const { url, id } =
46
+ typeof options === "string" ? { url: options, id: undefined } : (options ?? {});
47
+ try {
48
+ if (!_config) throw new Error("Call initServer() before tracking events");
49
+
50
+ const body = buildSendBody({
51
+ websiteId: _config.websiteId,
52
+ hostname: _config.hostname,
53
+ name,
54
+ url,
55
+ data,
56
+ });
57
+ const payload = id ? { ...body.payload, id } : body.payload;
58
+
59
+ const response = await fetch(`${_config.hostUrl.replace(/\/$/, "")}${UMAMI_ENDPOINTS.send}`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "User-Agent": _config.userAgent ?? DEFAULT_USER_AGENT,
64
+ },
65
+ body: JSON.stringify({ ...body, payload }),
66
+ signal: AbortSignal.timeout(TIMEOUT_MS),
67
+ });
68
+ const text = await response.text();
69
+ if (!response.ok || text.includes("beep")) {
70
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 120)}`);
71
+ }
72
+ return true;
73
+ } catch (err: unknown) {
74
+ console.error(`[analytics] Server track failed for ${name}:`, err);
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /** Track a server-side revenue event. Revenue must be in dollars (not cents). */
80
+ export function trackServerRevenue(
81
+ name: string,
82
+ revenue: number,
83
+ currency: string,
84
+ data?: EventData,
85
+ options?: string | ServerEventOptions,
86
+ ): Promise<boolean> {
87
+ return trackServer(name, { ...data, revenue, currency }, options);
88
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from "vite-plus/test";
2
+ import { umamiScriptAttrs } from "#src/analytics/svelte.js";
3
+
4
+ const DEFAULT_HOST = "https://your-umami-instance.com";
5
+
6
+ describe("umamiScriptAttrs", () => {
7
+ it("returns src and data-website-id using the default host when no options are given", () => {
8
+ const attrs = umamiScriptAttrs("my-website-id");
9
+ expect(attrs).toEqual({
10
+ src: `${DEFAULT_HOST}/script.js`,
11
+ "data-website-id": "my-website-id",
12
+ });
13
+ });
14
+
15
+ it("uses a custom hostUrl when provided", () => {
16
+ const attrs = umamiScriptAttrs("my-website-id", {
17
+ hostUrl: "https://custom.umami.io",
18
+ });
19
+ expect(attrs.src).toBe("https://custom.umami.io/script.js");
20
+ });
21
+
22
+ it("sets data-website-id from the first argument", () => {
23
+ const attrs = umamiScriptAttrs("site-abc");
24
+ expect(attrs["data-website-id"]).toBe("site-abc");
25
+ });
26
+
27
+ it("adds data-domains when domains are provided", () => {
28
+ const attrs = umamiScriptAttrs("site-id", {
29
+ domains: ["myapp.com", "www.myapp.com"],
30
+ });
31
+ expect(attrs["data-domains"]).toBe("myapp.com,www.myapp.com");
32
+ });
33
+
34
+ it("does not add data-domains when domains array is empty", () => {
35
+ const attrs = umamiScriptAttrs("site-id", { domains: [] });
36
+ expect(attrs).not.toHaveProperty("data-domains");
37
+ });
38
+
39
+ it("does not add data-domains when domains option is absent", () => {
40
+ const attrs = umamiScriptAttrs("site-id");
41
+ expect(attrs).not.toHaveProperty("data-domains");
42
+ });
43
+
44
+ it("adds data-before-send when beforeSend is provided", () => {
45
+ const attrs = umamiScriptAttrs("site-id", { beforeSend: "umamiBeforeSend" });
46
+ expect(attrs["data-before-send"]).toBe("umamiBeforeSend");
47
+ });
48
+
49
+ it("does not add data-before-send when beforeSend is absent", () => {
50
+ const attrs = umamiScriptAttrs("site-id");
51
+ expect(attrs).not.toHaveProperty("data-before-send");
52
+ });
53
+
54
+ it("adds data-do-not-track=true when doNotTrack is true", () => {
55
+ const attrs = umamiScriptAttrs("site-id", { doNotTrack: true });
56
+ expect(attrs["data-do-not-track"]).toBe("true");
57
+ });
58
+
59
+ it("does not add data-do-not-track when doNotTrack is false", () => {
60
+ const attrs = umamiScriptAttrs("site-id", { doNotTrack: false });
61
+ expect(attrs).not.toHaveProperty("data-do-not-track");
62
+ });
63
+
64
+ it("does not add data-do-not-track when doNotTrack is absent", () => {
65
+ const attrs = umamiScriptAttrs("site-id");
66
+ expect(attrs).not.toHaveProperty("data-do-not-track");
67
+ });
68
+
69
+ it("adds data-performance=true when performance is true", () => {
70
+ const attrs = umamiScriptAttrs("site-id", { performance: true });
71
+ expect(attrs["data-performance"]).toBe("true");
72
+ });
73
+
74
+ it("does not add data-performance when performance is false or absent", () => {
75
+ expect(umamiScriptAttrs("site-id")).not.toHaveProperty("data-performance");
76
+ expect(umamiScriptAttrs("site-id", { performance: false })).not.toHaveProperty(
77
+ "data-performance",
78
+ );
79
+ });
80
+
81
+ it("adds data-tag when tag is provided", () => {
82
+ const attrs = umamiScriptAttrs("site-id", { tag: "v2" });
83
+ expect(attrs["data-tag"]).toBe("v2");
84
+ });
85
+
86
+ it("all optional attributes can be combined", () => {
87
+ const attrs = umamiScriptAttrs("site-id", {
88
+ hostUrl: "https://u.example.com",
89
+ domains: ["app.com"],
90
+ beforeSend: "handler",
91
+ doNotTrack: true,
92
+ performance: true,
93
+ tag: "release-42",
94
+ });
95
+ expect(attrs).toEqual({
96
+ src: "https://u.example.com/script.js",
97
+ "data-website-id": "site-id",
98
+ "data-domains": "app.com",
99
+ "data-before-send": "handler",
100
+ "data-do-not-track": "true",
101
+ "data-performance": "true",
102
+ "data-tag": "release-42",
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Svelte component for loading the Umami script tag.
3
+ */
4
+
5
+ /*
6
+ * Usage in +layout.svelte: import { umamiScriptAttrs } from "@heyramzi/utils/analytics/svelte";
7
+ */
8
+
9
+ /*
10
+ * <svelte:head> <script defer {...umamiScriptAttrs("website-id")} /> </svelte:head>
11
+ */
12
+
13
+ /*
14
+ * Or with beforeSend: define window.umamiBeforeSend yourself and pass its name below.
15
+ */
16
+
17
+ /*
18
+ * <script defer {...umamiScriptAttrs("website-id", { beforeSend: "umamiBeforeSend" })} />
19
+ */
20
+
21
+ const DEFAULT_HOST = "https://your-umami-instance.com";
22
+
23
+ type ScriptOptions = {
24
+ hostUrl?: string;
25
+ domains?: string[];
26
+ beforeSend?: string;
27
+ doNotTrack?: boolean;
28
+ performance?: boolean;
29
+ tag?: string;
30
+ };
31
+
32
+ /** Generate attributes for the Umami script tag. */
33
+ export function umamiScriptAttrs(
34
+ websiteId: string,
35
+ options?: ScriptOptions,
36
+ ): Record<string, string> {
37
+ const host = options?.hostUrl ?? DEFAULT_HOST;
38
+ const attrs: Record<string, string> = {
39
+ src: `${host}/script.js`,
40
+ "data-website-id": websiteId,
41
+ };
42
+ if (options?.domains?.length) {
43
+ attrs["data-domains"] = options.domains.join(",");
44
+ }
45
+ if (options?.beforeSend) {
46
+ attrs["data-before-send"] = options.beforeSend;
47
+ }
48
+ if (options?.doNotTrack) {
49
+ attrs["data-do-not-track"] = "true";
50
+ }
51
+ if (options?.performance) {
52
+ attrs["data-performance"] = "true";
53
+ }
54
+ if (options?.tag) {
55
+ attrs["data-tag"] = options.tag;
56
+ }
57
+ return attrs;
58
+ }
@@ -0,0 +1,26 @@
1
+ export type EventData = Record<string, string | number | boolean>;
2
+
3
+ export type RevenueData = {
4
+ revenue: number;
5
+ currency: string;
6
+ } & EventData;
7
+
8
+ export type UmamiConfig = {
9
+ websiteId: string;
10
+ hostUrl?: string;
11
+ domains?: string[];
12
+ };
13
+
14
+ declare global {
15
+ interface Window {
16
+ umami?: {
17
+ track: {
18
+ (): void;
19
+ (event: string): void;
20
+ (event: string, data: Record<string, unknown>): void;
21
+ (fn: (props: Record<string, unknown>) => Record<string, unknown>): void;
22
+ };
23
+ identify?: (data: Record<string, unknown>) => void;
24
+ };
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { buildSendBody } from "#src/analytics/umami/protocol.js";
3
+
4
+ describe("buildSendBody", () => {
5
+ // Apps Script appends its own tag to any header, so payload.userAgent is the only way past the bot filter.
6
+ it("carries userAgent into the payload", () => {
7
+ const body = buildSendBody({
8
+ websiteId: "site-1",
9
+ name: "sync_run",
10
+ userAgent: "Mozilla/5.0 airtable-to-sheets/1.0",
11
+ });
12
+ expect(body.payload.userAgent).toBe("Mozilla/5.0 airtable-to-sheets/1.0");
13
+ });
14
+
15
+ it("leaves userAgent out when none is given", () => {
16
+ expect(buildSendBody({ websiteId: "site-1" }).payload).not.toHaveProperty("userAgent");
17
+ });
18
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure Umami wire-protocol helpers: no I/O, no global dependencies.
3
+ */
4
+
5
+ /*
6
+ * Consumers in any runtime (browser, Node, Convex actions, Apps Script, service workers, edge functions, Chrome extensions) can use these to build a request body for Umami's `/api/send` endpoint,
7
+ */
8
+
9
+ /*
10
+ * then plug in their own transport (fetch, UrlFetchApp.fetch, etc.) without re-implementing the protocol details.
11
+ */
12
+
13
+ /*
14
+ * Umami's `/api/send` accepts one event per request. Authentication is via the website ID embedded in the payload, so there's no API key.
15
+ */
16
+
17
+ /*
18
+ * The User-Agent header is required; without it the request is dropped.
19
+ */
20
+
21
+ import type { EventData } from "#src/analytics/types.js";
22
+
23
+ export const UMAMI_ENDPOINTS = {
24
+ send: "/api/send",
25
+ } as const;
26
+
27
+ export type SendBodyInput = {
28
+ /** Umami website ID (UUID). */
29
+ websiteId: string;
30
+ /** Event name. Required for custom events; omit to record a raw pageview. */
31
+ name?: string;
32
+ /** Pathname (or full URL) the event is associated with. Defaults to "/". */
33
+ url?: string;
34
+ /** Hostname the event came from. Optional but useful for multi-domain dashboards. */
35
+ hostname?: string;
36
+ /** Page title. Optional. */
37
+ title?: string;
38
+ /** Referrer URL. Optional. */
39
+ referrer?: string;
40
+ /** Browser language tag (e.g. "en-US"). Optional. */
41
+ language?: string;
42
+ /** Screen size (e.g. "1920x1080"). Optional. */
43
+ screen?: string;
44
+ /** Custom event data. Coerced via `coerceData`. */
45
+ data?: EventData | Record<string, unknown>;
46
+ /**
47
+ * Overrides the User-Agent header. Umami answers a bot agent with `beep` and stores nothing, and Apps Script can't set a clean header, so send `Mozilla/5.0 <name>/<version>` here.
48
+ */
49
+ userAgent?: string;
50
+ };
51
+
52
+ export type SendBody = {
53
+ type: "event";
54
+ payload: {
55
+ website: string;
56
+ url: string;
57
+ name?: string;
58
+ hostname?: string;
59
+ title?: string;
60
+ referrer?: string;
61
+ language?: string;
62
+ screen?: string;
63
+ data?: Record<string, string | number | boolean>;
64
+ userAgent?: string;
65
+ };
66
+ };
67
+
68
+ /**
69
+ * Coerce arbitrary event data to Umami-safe primitives. Umami accepts
70
+ * string/number/boolean values in `data`. Nulls and undefineds are dropped.
71
+ */
72
+ export function coerceData(
73
+ data?: EventData | Record<string, unknown>,
74
+ ): Record<string, string | number | boolean> | undefined {
75
+ if (!data) return undefined;
76
+ const out: Record<string, string | number | boolean> = {};
77
+ for (const [key, value] of Object.entries(data)) {
78
+ if (value === undefined || value === null) continue;
79
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
80
+ out[key] = value;
81
+ } else {
82
+ out[key] = String(value);
83
+ }
84
+ }
85
+ return Object.keys(out).length > 0 ? out : undefined;
86
+ }
87
+
88
+ /** Build the JSON body for a POST to Umami's `/api/send`. */
89
+ export function buildSendBody(input: SendBodyInput): SendBody {
90
+ const data = coerceData(input.data);
91
+ const payload: SendBody["payload"] = {
92
+ website: input.websiteId,
93
+ url: input.url ?? "/",
94
+ };
95
+ if (input.name) payload.name = input.name;
96
+ if (input.hostname) payload.hostname = input.hostname;
97
+ if (input.title) payload.title = input.title;
98
+ if (input.referrer) payload.referrer = input.referrer;
99
+ if (input.language) payload.language = input.language;
100
+ if (input.screen) payload.screen = input.screen;
101
+ if (data) payload.data = data;
102
+ if (input.userAgent) payload.userAgent = input.userAgent;
103
+ return { type: "event", payload };
104
+ }