@heyramzi/marketing 0.0.0-stage → 2.0.1
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 +21 -0
- package/README.md +92 -2
- package/package.json +67 -3
- package/src/analytics/server.test.ts +282 -0
- package/src/analytics/server.ts +72 -0
- package/src/analytics/svelte.test.ts +141 -0
- package/src/analytics/svelte.ts +70 -0
- package/src/analytics/types.ts +26 -0
- package/src/analytics/umami/protocol.ts +98 -0
- package/src/analytics/umami/reporting.test.ts +391 -0
- package/src/analytics/umami/reporting.ts +627 -0
- package/src/analytics/unified.test.ts +178 -0
- package/src/analytics/unified.ts +169 -0
- package/src/brand/index.ts +399 -0
- package/src/errors/sentry/index.ts +20 -0
- package/src/errors/sentry/protocol.test.ts +63 -0
- package/src/errors/sentry/protocol.ts +120 -0
- package/src/imagegen/fonts.ts +74 -0
- package/src/imagegen/index.ts +11 -0
- package/src/imagegen/markup.test.ts +73 -0
- package/src/imagegen/markup.ts +90 -0
- package/src/imagegen/renderer.ts +83 -0
- package/src/imagegen/types.ts +26 -0
- package/src/imagegen/wawoff2.d.ts +6 -0
- package/src/svg-raw.d.ts +7 -0
- package/src/testimonials/mux.test.ts +37 -0
- package/src/testimonials/mux.ts +22 -0
- package/src/testimonials/render-content.ts +21 -0
- package/src/testimonials/server.ts +47 -0
- package/src/testimonials/svelte/StarRating.svelte +34 -0
- package/src/testimonials/svelte/TestimonialCarousel.svelte +132 -0
- package/src/testimonials/svelte/TestimonialCompact.svelte +93 -0
- package/src/testimonials/svelte/TestimonialGrid.svelte +182 -0
- package/src/testimonials/svelte/TestimonialHighlight.svelte +80 -0
- package/src/testimonials/svelte/TestimonialVideoPlayer.svelte +217 -0
- package/src/testimonials/svelte/Testimonials.svelte +38 -0
- package/src/testimonials/svelte/VideoEmbed.svelte +170 -0
- package/src/testimonials/svelte/index.ts +19 -0
- package/src/testimonials/svelte/pause-icon.svg +1 -0
- package/src/testimonials/svelte/play-icon.svg +1 -0
- package/src/testimonials/svelte/quote-icon.svg +1 -0
- package/src/testimonials/svelte/star-icon.svg +1 -0
- package/src/testimonials/svelte/types.ts +7 -0
- package/src/testimonials/types.ts +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ramzi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @heyramzi/marketing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Marketing utilities for the vibe-kit ecosystem: analytics plumbing and a generic HTML→image rendering pipeline.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Umami analytics**: the browser script helper, a server tracker, a one-call unified tracker, the wire protocol for edge runtimes, and a reporting API client to read traffic back.
|
|
8
|
+
- **Sentry**: a protocol split for runtimes that cannot load the SDK, and an SDK wrapper.
|
|
9
|
+
- **Image generation (`imagegen`)**: Generic satori-based HTML to SVG/PNG/AVIF pipeline. Consumers bring their own templates.
|
|
10
|
+
- **Brand and testimonials**: brand tokens and testimonial types, server loaders and Svelte surfaces.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @heyramzi/marketing
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Analytics
|
|
21
|
+
|
|
22
|
+
The analytics module provides wrappers for Umami tracking across different environments.
|
|
23
|
+
|
|
24
|
+
#### Server-side (Node.js/SvelteKit)
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { initServer, trackServer } from '@heyramzi/marketing/analytics/server';
|
|
28
|
+
|
|
29
|
+
initServer({ websiteId: process.env.UMAMI_WEBSITE_ID });
|
|
30
|
+
await trackServer('signup', { plan: 'pro' });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### Script tag and browser tracking
|
|
34
|
+
|
|
35
|
+
```svelte
|
|
36
|
+
<script>
|
|
37
|
+
import { umamiScriptAttrs } from '@heyramzi/marketing/analytics/svelte';
|
|
38
|
+
import { initAnalytics, track } from '@heyramzi/marketing/analytics/unified';
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<svelte:head>
|
|
42
|
+
<script defer {...umamiScriptAttrs("your-id")}></script>
|
|
43
|
+
</svelte:head>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`analytics/unified` is the browser tracker: `initAnalytics` once (it returns a cleanup function), then `track`, `trackError`, `trackRevenue` and `setProfileId`.
|
|
47
|
+
|
|
48
|
+
#### Reading traffic back out
|
|
49
|
+
|
|
50
|
+
`analytics/unified` and `analytics/server` write events. `analytics/umami/reporting` reads them: websites, stats, metrics, time-series, sessions and live visitors. It is the one client for every runtime, so a Cloudflare Worker, a Node server and a CLI all read the same numbers the same way. Transport is the only thing injected.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { UmamiReportingClient } from "@heyramzi/marketing/analytics/umami/reporting";
|
|
54
|
+
|
|
55
|
+
const client = new UmamiReportingClient({
|
|
56
|
+
apiUrl: process.env.UMAMI_API_URL,
|
|
57
|
+
username: process.env.UMAMI_USERNAME,
|
|
58
|
+
password: process.env.UMAMI_PASSWORD,
|
|
59
|
+
// Required when the Umami host sits behind Cloudflare Access, which answers
|
|
60
|
+
// an unauthenticated API call with an HTML login page instead of JSON.
|
|
61
|
+
accessClientId: process.env.CF_ACCESS_CLIENT_ID,
|
|
62
|
+
accessClientSecret: process.env.CF_ACCESS_CLIENT_SECRET,
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Note that the server returns the current window flat and the preceding window under `comparison`, not `{ value, prev }` per metric.
|
|
67
|
+
|
|
68
|
+
### Image Generation
|
|
69
|
+
|
|
70
|
+
Generic HTML→image pipeline built on satori + sharp. Supports SVG, PNG, and AVIF. Fonts can be loaded from `.ttf`, `.otf`, or `.woff2` files (woff2 is decoded transparently).
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { html } from 'satori-html';
|
|
74
|
+
import { loadFonts, renderImage } from '@heyramzi/marketing/imagegen';
|
|
75
|
+
|
|
76
|
+
const fonts = await loadFonts([
|
|
77
|
+
{ name: 'Manrope', path: '/path/to/manrope-700.woff2', weight: 700 },
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const markup = html`<div style="display:flex;">Hello</div>`;
|
|
81
|
+
|
|
82
|
+
const png = await renderImage(markup, {
|
|
83
|
+
fonts,
|
|
84
|
+
size: { width: 1200, height: 630 },
|
|
85
|
+
format: 'png',
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Templates, handlers, and query-param plumbing belong in the consuming app. This package intentionally ships only the rendering primitives.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
- `pnpm test`: Run vitest suites.
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/marketing",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Umami analytics for client, server, and Svelte, plus a satori-based HTML-to-image pipeline.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/heyramzi/ui.git",
|
|
9
|
+
"directory": "packages/marketing"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"package.json"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"imports": {
|
|
17
|
+
"#src/*.js": "./src/*.ts",
|
|
18
|
+
"#src/*": "./src/*"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./analytics/server": "./src/analytics/server.ts",
|
|
22
|
+
"./analytics/svelte": "./src/analytics/svelte.ts",
|
|
23
|
+
"./analytics/umami/protocol": "./src/analytics/umami/protocol.ts",
|
|
24
|
+
"./analytics/umami/reporting": "./src/analytics/umami/reporting.ts",
|
|
25
|
+
"./analytics/unified": "./src/analytics/unified.ts",
|
|
26
|
+
"./imagegen": "./src/imagegen/index.ts",
|
|
27
|
+
"./brand": "./src/brand/index.ts",
|
|
28
|
+
"./testimonials/types": "./src/testimonials/types.ts",
|
|
29
|
+
"./testimonials/server": "./src/testimonials/server.ts",
|
|
30
|
+
"./testimonials/render-content": "./src/testimonials/render-content.ts",
|
|
31
|
+
"./testimonials/svelte": {
|
|
32
|
+
"svelte": "./src/testimonials/svelte/index.ts",
|
|
33
|
+
"types": "./src/testimonials/svelte/index.ts",
|
|
34
|
+
"default": "./src/testimonials/svelte/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"./errors/sentry": "./src/errors/sentry/index.ts",
|
|
37
|
+
"./errors/sentry/protocol": "./src/errors/sentry/protocol.ts"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"satori": "^0.35.0",
|
|
41
|
+
"satori-html": "^0.3.2",
|
|
42
|
+
"ultrahtml": "^1.7.0",
|
|
43
|
+
"wawoff2": "^2.0.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"vite-plus": "^1.0.0",
|
|
47
|
+
"vitest": "^5.0.3"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@heyramzi/utils": "^1.3.3",
|
|
51
|
+
"@mux/mux-player": ">=3.0.0",
|
|
52
|
+
"@umami/node": ">=0.4.0",
|
|
53
|
+
"phosphor-svelte": ">=3.0.0",
|
|
54
|
+
"sharp": ">=0.33.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@umami/node": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"phosphor-svelte": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@mux/mux-player": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "vp test run"
|
|
69
|
+
}
|
|
6
70
|
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vite-plus/test";
|
|
2
|
+
|
|
3
|
+
// The server module holds module-level singletons (client, _config).
|
|
4
|
+
// Each test resets them by calling vi.resetModules() + re-importing.
|
|
5
|
+
|
|
6
|
+
async function freshImport() {
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
return import("#src/analytics/server.js");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ---- initServer ----
|
|
16
|
+
|
|
17
|
+
describe("initServer", () => {
|
|
18
|
+
it("stores config so that subsequent trackServer calls succeed", async () => {
|
|
19
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
20
|
+
|
|
21
|
+
vi.doMock("@umami/node", () => ({
|
|
22
|
+
Umami: class {
|
|
23
|
+
track = mockTrack;
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const { initServer, trackServer } = await freshImport();
|
|
28
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
29
|
+
trackServer("test_event");
|
|
30
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
31
|
+
|
|
32
|
+
expect(mockTrack).toHaveBeenCalledWith({ name: "test_event" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("throws when trackServer is called without prior initServer", async () => {
|
|
36
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
37
|
+
vi.doMock("@umami/node", () => ({ Umami: class {} }));
|
|
38
|
+
|
|
39
|
+
const { trackServer } = await freshImport();
|
|
40
|
+
trackServer("orphan_event");
|
|
41
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
42
|
+
|
|
43
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
44
|
+
"[analytics] Server track failed:",
|
|
45
|
+
expect.objectContaining({ message: "Call initServer() before tracking events" }),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ---- trackServer ----
|
|
51
|
+
|
|
52
|
+
describe("trackServer", () => {
|
|
53
|
+
it("builds a payload with only name when no data or url is given", async () => {
|
|
54
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
55
|
+
vi.doMock("@umami/node", () => ({
|
|
56
|
+
Umami: class {
|
|
57
|
+
track = mockTrack;
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
const { initServer, trackServer } = await freshImport();
|
|
62
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
63
|
+
trackServer("signup");
|
|
64
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
65
|
+
|
|
66
|
+
expect(mockTrack).toHaveBeenCalledWith({ name: "signup" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("includes data in the payload when provided", async () => {
|
|
70
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
71
|
+
vi.doMock("@umami/node", () => ({
|
|
72
|
+
Umami: class {
|
|
73
|
+
track = mockTrack;
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const { initServer, trackServer } = await freshImport();
|
|
78
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
79
|
+
trackServer("feature_used", { feature: "export", count: 5 });
|
|
80
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
81
|
+
|
|
82
|
+
expect(mockTrack).toHaveBeenCalledWith({
|
|
83
|
+
name: "feature_used",
|
|
84
|
+
data: { feature: "export", count: 5 },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("includes url in the payload when provided", async () => {
|
|
89
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
90
|
+
vi.doMock("@umami/node", () => ({
|
|
91
|
+
Umami: class {
|
|
92
|
+
track = mockTrack;
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const { initServer, trackServer } = await freshImport();
|
|
97
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
98
|
+
trackServer("page_view", undefined, "/pricing");
|
|
99
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
100
|
+
|
|
101
|
+
expect(mockTrack).toHaveBeenCalledWith({ name: "page_view", url: "/pricing" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("includes both data and url when both are provided", async () => {
|
|
105
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
106
|
+
vi.doMock("@umami/node", () => ({
|
|
107
|
+
Umami: class {
|
|
108
|
+
track = mockTrack;
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const { initServer, trackServer } = await freshImport();
|
|
113
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
114
|
+
trackServer("click", { label: "CTA" }, "/home");
|
|
115
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
116
|
+
|
|
117
|
+
expect(mockTrack).toHaveBeenCalledWith({
|
|
118
|
+
name: "click",
|
|
119
|
+
data: { label: "CTA" },
|
|
120
|
+
url: "/home",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("logs an error and does not throw when track rejects", async () => {
|
|
125
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
126
|
+
const mockTrack = vi.fn().mockRejectedValue(new Error("network failure"));
|
|
127
|
+
vi.doMock("@umami/node", () => ({
|
|
128
|
+
Umami: class {
|
|
129
|
+
track = mockTrack;
|
|
130
|
+
},
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const { initServer, trackServer } = await freshImport();
|
|
134
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
135
|
+
trackServer("risky_event");
|
|
136
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
137
|
+
|
|
138
|
+
expect(consoleSpy).toHaveBeenCalledWith("[analytics] Server track failed:", expect.any(Error));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reuses the same Umami instance across multiple calls", async () => {
|
|
142
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
143
|
+
let constructorCallCount = 0;
|
|
144
|
+
vi.doMock("@umami/node", () => ({
|
|
145
|
+
Umami: class {
|
|
146
|
+
constructor() {
|
|
147
|
+
constructorCallCount++;
|
|
148
|
+
}
|
|
149
|
+
track = mockTrack;
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const { initServer, trackServer } = await freshImport();
|
|
154
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
155
|
+
trackServer("first");
|
|
156
|
+
// Wait for the first getClient() promise to resolve and set the cached client
|
|
157
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
158
|
+
trackServer("second");
|
|
159
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
160
|
+
|
|
161
|
+
expect(constructorCallCount).toBe(1);
|
|
162
|
+
expect(mockTrack).toHaveBeenCalledTimes(2);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---- trackServerRevenue ----
|
|
167
|
+
|
|
168
|
+
describe("trackServerRevenue", () => {
|
|
169
|
+
it("tracks a revenue event with revenue and currency merged into data", async () => {
|
|
170
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
171
|
+
vi.doMock("@umami/node", () => ({
|
|
172
|
+
Umami: class {
|
|
173
|
+
track = mockTrack;
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
const { initServer, trackServerRevenue } = await freshImport();
|
|
178
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
179
|
+
trackServerRevenue("purchase", 49.99, "USD");
|
|
180
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
181
|
+
|
|
182
|
+
expect(mockTrack).toHaveBeenCalledWith({
|
|
183
|
+
name: "purchase",
|
|
184
|
+
data: { revenue: 49.99, currency: "USD" },
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("merges extra EventData alongside revenue and currency", async () => {
|
|
189
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
190
|
+
vi.doMock("@umami/node", () => ({
|
|
191
|
+
Umami: class {
|
|
192
|
+
track = mockTrack;
|
|
193
|
+
},
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
const { initServer, trackServerRevenue } = await freshImport();
|
|
197
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
198
|
+
trackServerRevenue("subscription", 9.99, "EUR", { plan: "pro" });
|
|
199
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
200
|
+
|
|
201
|
+
expect(mockTrack).toHaveBeenCalledWith({
|
|
202
|
+
name: "subscription",
|
|
203
|
+
data: { plan: "pro", revenue: 9.99, currency: "EUR" },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("passes url to the underlying trackServer call", async () => {
|
|
208
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
209
|
+
vi.doMock("@umami/node", () => ({
|
|
210
|
+
Umami: class {
|
|
211
|
+
track = mockTrack;
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
const { initServer, trackServerRevenue } = await freshImport();
|
|
216
|
+
initServer({ hostUrl: "https://umami.example.com", websiteId: "abc-123" });
|
|
217
|
+
trackServerRevenue("checkout", 19.99, "USD", undefined, "/checkout");
|
|
218
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
219
|
+
|
|
220
|
+
expect(mockTrack).toHaveBeenCalledWith({
|
|
221
|
+
name: "checkout",
|
|
222
|
+
data: { revenue: 19.99, currency: "USD" },
|
|
223
|
+
url: "/checkout",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---- initServer config forwarding ----
|
|
229
|
+
|
|
230
|
+
describe("initServer config forwarding", () => {
|
|
231
|
+
it("passes hostUrl and websiteId to the Umami constructor", async () => {
|
|
232
|
+
let capturedConfig = null;
|
|
233
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
234
|
+
vi.doMock("@umami/node", () => ({
|
|
235
|
+
Umami: class {
|
|
236
|
+
constructor(cfg: unknown) {
|
|
237
|
+
capturedConfig = cfg;
|
|
238
|
+
}
|
|
239
|
+
track = mockTrack;
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
const { initServer, trackServer } = await freshImport();
|
|
244
|
+
initServer({ hostUrl: "https://analytics.myapp.io", websiteId: "xyz-789" });
|
|
245
|
+
trackServer("init_test");
|
|
246
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
247
|
+
|
|
248
|
+
expect(capturedConfig).toEqual({
|
|
249
|
+
hostUrl: "https://analytics.myapp.io",
|
|
250
|
+
websiteId: "xyz-789",
|
|
251
|
+
userAgent: "Server",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("forwards a custom userAgent to the Umami constructor", async () => {
|
|
256
|
+
let capturedConfig = null;
|
|
257
|
+
const mockTrack = vi.fn().mockResolvedValue(undefined);
|
|
258
|
+
vi.doMock("@umami/node", () => ({
|
|
259
|
+
Umami: class {
|
|
260
|
+
constructor(cfg: unknown) {
|
|
261
|
+
capturedConfig = cfg;
|
|
262
|
+
}
|
|
263
|
+
track = mockTrack;
|
|
264
|
+
},
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
const { initServer, trackServer } = await freshImport();
|
|
268
|
+
initServer({
|
|
269
|
+
hostUrl: "https://umami.example.com",
|
|
270
|
+
websiteId: "abc-123",
|
|
271
|
+
userAgent: "MyBot/1.0",
|
|
272
|
+
});
|
|
273
|
+
trackServer("ua_test");
|
|
274
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
275
|
+
|
|
276
|
+
expect(capturedConfig).toEqual({
|
|
277
|
+
hostUrl: "https://umami.example.com",
|
|
278
|
+
websiteId: "abc-123",
|
|
279
|
+
userAgent: "MyBot/1.0",
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Umami tracking via @umami/node.
|
|
3
|
+
* Reliable for revenue and critical events — bypasses ad blockers.
|
|
4
|
+
*
|
|
5
|
+
* Requires `@umami/node` peer dependency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EventData } from "#src/analytics/types.js";
|
|
9
|
+
|
|
10
|
+
type UmamiNodeClient = {
|
|
11
|
+
track: (payload: Record<string, unknown>) => Promise<unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type UmamiNodeModule = {
|
|
15
|
+
Umami: new (config: {
|
|
16
|
+
hostUrl: string;
|
|
17
|
+
websiteId: string;
|
|
18
|
+
userAgent?: string;
|
|
19
|
+
}) => UmamiNodeClient;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let client: UmamiNodeClient | null = null;
|
|
23
|
+
|
|
24
|
+
type ServerConfig = {
|
|
25
|
+
hostUrl: string;
|
|
26
|
+
websiteId: string;
|
|
27
|
+
userAgent?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let _config: ServerConfig | null = null;
|
|
31
|
+
|
|
32
|
+
/** Initialize the server-side Umami client. Call once at startup. */
|
|
33
|
+
export function initServer(config: ServerConfig): void {
|
|
34
|
+
_config = config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getClient(): Promise<UmamiNodeClient> {
|
|
38
|
+
if (client) return client;
|
|
39
|
+
if (!_config) throw new Error("Call initServer() before tracking events");
|
|
40
|
+
|
|
41
|
+
const { Umami } = (await import("@umami/node")) as UmamiNodeModule;
|
|
42
|
+
client = new Umami({
|
|
43
|
+
hostUrl: _config.hostUrl,
|
|
44
|
+
websiteId: _config.websiteId,
|
|
45
|
+
userAgent: _config.userAgent ?? "Server",
|
|
46
|
+
});
|
|
47
|
+
return client;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fire-and-forget server-side event tracking. Never throws. */
|
|
51
|
+
export function trackServer(name: string, data?: EventData, url?: string): void {
|
|
52
|
+
const payload: { name: string; data?: EventData; url?: string } = { name };
|
|
53
|
+
if (data) payload.data = data;
|
|
54
|
+
if (url) payload.url = url;
|
|
55
|
+
|
|
56
|
+
getClient()
|
|
57
|
+
.then((c) => c.track(payload))
|
|
58
|
+
.catch((err: unknown) => {
|
|
59
|
+
console.error("[analytics] Server track failed:", err);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Track a server-side revenue event. Revenue must be in dollars (not cents). */
|
|
64
|
+
export function trackServerRevenue(
|
|
65
|
+
name: string,
|
|
66
|
+
revenue: number,
|
|
67
|
+
currency: string,
|
|
68
|
+
data?: EventData,
|
|
69
|
+
url?: string,
|
|
70
|
+
): void {
|
|
71
|
+
trackServer(name, { ...data, revenue, currency }, url);
|
|
72
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test";
|
|
2
|
+
import { umamiScriptAttrs, umamiPreconnectAttrs } 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
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("umamiPreconnectAttrs", () => {
|
|
108
|
+
it("returns preconnect and dns-prefetch objects with the default host", () => {
|
|
109
|
+
const result = umamiPreconnectAttrs();
|
|
110
|
+
expect(result.preconnect).toEqual({
|
|
111
|
+
rel: "preconnect",
|
|
112
|
+
href: DEFAULT_HOST,
|
|
113
|
+
});
|
|
114
|
+
expect(result.dnsPrefetch).toEqual({
|
|
115
|
+
rel: "dns-prefetch",
|
|
116
|
+
href: DEFAULT_HOST,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses the provided hostUrl", () => {
|
|
121
|
+
const result = umamiPreconnectAttrs("https://analytics.example.com");
|
|
122
|
+
expect(result.preconnect.href).toBe("https://analytics.example.com");
|
|
123
|
+
expect(result.dnsPrefetch.href).toBe("https://analytics.example.com");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("preconnect has rel=preconnect", () => {
|
|
127
|
+
const result = umamiPreconnectAttrs();
|
|
128
|
+
expect(result.preconnect.rel).toBe("preconnect");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("dnsPrefetch has rel=dns-prefetch", () => {
|
|
132
|
+
const result = umamiPreconnectAttrs();
|
|
133
|
+
expect(result.dnsPrefetch.rel).toBe("dns-prefetch");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("both entries point to the same host", () => {
|
|
137
|
+
const customHost = "https://my-umami.io";
|
|
138
|
+
const result = umamiPreconnectAttrs(customHost);
|
|
139
|
+
expect(result.preconnect.href).toBe(result.dnsPrefetch.href);
|
|
140
|
+
});
|
|
141
|
+
});
|