@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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/dist/ai/image.js +220 -0
- package/dist/ai/models.js +261 -0
- package/dist/analytics/client.js +57 -0
- package/dist/analytics/server.js +54 -0
- package/dist/analytics/svelte.js +24 -0
- package/dist/analytics/types.js +0 -0
- package/dist/analytics/umami/protocol.js +35 -0
- package/dist/analytics/umami/reporting.js +189 -0
- package/dist/auth/config/otp.js +4 -0
- package/dist/auth/config/rate-limits.js +20 -0
- package/dist/auth/config/trusted-origins.js +15 -0
- package/dist/auth/email/index.js +3 -0
- package/dist/auth/email/layout.js +84 -0
- package/dist/auth/email/resend.js +30 -0
- package/dist/auth/email/template.js +57 -0
- package/dist/auth/email/types.js +0 -0
- package/dist/auth/index.js +3 -0
- package/dist/cache.js +4 -0
- package/dist/cn.js +5 -0
- package/dist/collection.js +8 -0
- package/dist/crypto.js +117 -0
- package/dist/error.js +15 -0
- package/dist/errors/sentry/index.js +15 -0
- package/dist/errors/sentry/protocol.js +130 -0
- package/dist/errors/sentry/sveltekit.js +145 -0
- package/dist/format.js +62 -0
- package/dist/grab.js +112 -0
- package/dist/index.js +10 -0
- package/dist/logger-types.js +0 -0
- package/dist/logger.js +22 -0
- package/dist/math.js +3 -0
- package/dist/og/fonts.js +46 -0
- package/dist/og/index.js +3 -0
- package/dist/og/markup.js +31 -0
- package/dist/og/renderer.js +29 -0
- package/dist/og/types.js +0 -0
- package/dist/result.js +26 -0
- package/dist/string.js +32 -0
- package/dist/testimonials/render-content.js +15 -0
- package/dist/testimonials/server.js +21 -0
- package/dist/testimonials/types.js +0 -0
- package/dist/timing.js +20 -0
- package/dist/vite.js +265 -0
- package/package.json +201 -4
- package/src/ai/image-agnes.test.ts +130 -0
- package/src/ai/image.ts +435 -0
- package/src/ai/models.test.ts +98 -0
- package/src/ai/models.ts +450 -0
- package/src/analytics/client.test.ts +149 -0
- package/src/analytics/client.ts +129 -0
- package/src/analytics/server.test.ts +165 -0
- package/src/analytics/server.ts +88 -0
- package/src/analytics/svelte.test.ts +105 -0
- package/src/analytics/svelte.ts +58 -0
- package/src/analytics/types.ts +26 -0
- package/src/analytics/umami/protocol.test.ts +18 -0
- package/src/analytics/umami/protocol.ts +104 -0
- package/src/analytics/umami/reporting.test.ts +391 -0
- package/src/analytics/umami/reporting.ts +627 -0
- package/src/auth/config/otp.ts +8 -0
- package/src/auth/config/rate-limits.ts +11 -0
- package/src/auth/config/trusted-origins.ts +32 -0
- package/src/auth/email/index.ts +7 -0
- package/src/auth/email/layout.test.ts +73 -0
- package/src/auth/email/layout.ts +122 -0
- package/src/auth/email/resend.test.ts +35 -0
- package/src/auth/email/resend.ts +26 -0
- package/src/auth/email/template.ts +77 -0
- package/src/auth/email/types.ts +74 -0
- package/src/auth/index.ts +5 -0
- package/src/cache.test.ts +67 -0
- package/src/cache.ts +5 -0
- package/src/cn.test.ts +52 -0
- package/src/cn.ts +16 -0
- package/src/collection.test.ts +66 -0
- package/src/collection.ts +8 -0
- package/src/crypto.test.ts +86 -0
- package/src/crypto.ts +157 -0
- package/src/error.test.ts +79 -0
- package/src/error.ts +16 -0
- package/src/errors/sentry/index.ts +20 -0
- package/src/errors/sentry/protocol.test.ts +85 -0
- package/src/errors/sentry/protocol.ts +144 -0
- package/src/errors/sentry/sveltekit.test.ts +149 -0
- package/src/errors/sentry/sveltekit.ts +226 -0
- package/src/format.test.ts +225 -0
- package/src/format.ts +103 -0
- package/src/grab.d.ts +2 -0
- package/src/grab.js +112 -0
- package/src/index.ts +38 -0
- package/src/logger-types.ts +14 -0
- package/src/logger.ts +24 -0
- package/src/math.test.ts +41 -0
- package/src/math.ts +3 -0
- package/src/og/fonts.ts +69 -0
- package/src/og/index.ts +5 -0
- package/src/og/markup.test.ts +73 -0
- package/src/og/markup.ts +90 -0
- package/src/og/renderer.ts +42 -0
- package/src/og/types.ts +26 -0
- package/src/og/wawoff2.d.ts +6 -0
- package/src/package-imports.test.mjs +22 -0
- package/src/result.test.ts +149 -0
- package/src/result.ts +29 -0
- package/src/string.test.ts +205 -0
- package/src/string.ts +71 -0
- package/src/testimonials/render-content.ts +21 -0
- package/src/testimonials/server.ts +47 -0
- package/src/testimonials/types.ts +35 -0
- package/src/timing.test.ts +166 -0
- package/src/timing.ts +28 -0
- package/src/vite.d.ts +12 -0
- package/src/vite.js +265 -0
- package/src/vite.test.js +235 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { buildSendBody, UMAMI_ENDPOINTS } from "./umami/protocol.js";
|
|
2
|
+
const DEFAULT_USER_AGENT = "Mozilla/5.0 Marketing/1.0";
|
|
3
|
+
const TIMEOUT_MS = 3000;
|
|
4
|
+
let _config = null;
|
|
5
|
+
export function initServer(config) {
|
|
6
|
+
_config = config;
|
|
7
|
+
}
|
|
8
|
+
export async function trackServer(name, data, options) {
|
|
9
|
+
const { url, id } = typeof options === "string" ? {
|
|
10
|
+
url: options,
|
|
11
|
+
id: undefined
|
|
12
|
+
} : options ?? {};
|
|
13
|
+
try {
|
|
14
|
+
if (!_config) throw new Error("Call initServer() before tracking events");
|
|
15
|
+
const body = buildSendBody({
|
|
16
|
+
websiteId: _config.websiteId,
|
|
17
|
+
hostname: _config.hostname,
|
|
18
|
+
name,
|
|
19
|
+
url,
|
|
20
|
+
data
|
|
21
|
+
});
|
|
22
|
+
const payload = id ? {
|
|
23
|
+
...body.payload,
|
|
24
|
+
id
|
|
25
|
+
} : body.payload;
|
|
26
|
+
const response = await fetch(`${_config.hostUrl.replace(/\/$/, "")}${UMAMI_ENDPOINTS.send}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"User-Agent": _config.userAgent ?? DEFAULT_USER_AGENT
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
...body,
|
|
34
|
+
payload
|
|
35
|
+
}),
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
37
|
+
});
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
if (!response.ok || text.includes("beep")) {
|
|
40
|
+
throw new Error(`HTTP ${response.status}: ${text.slice(0, 120)}`);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`[analytics] Server track failed for ${name}:`, err);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function trackServerRevenue(name, revenue, currency, data, options) {
|
|
49
|
+
return trackServer(name, {
|
|
50
|
+
...data,
|
|
51
|
+
revenue,
|
|
52
|
+
currency
|
|
53
|
+
}, options);
|
|
54
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const DEFAULT_HOST = "https://your-umami-instance.com";
|
|
2
|
+
export function umamiScriptAttrs(websiteId, options) {
|
|
3
|
+
const host = options?.hostUrl ?? DEFAULT_HOST;
|
|
4
|
+
const attrs = {
|
|
5
|
+
src: `${host}/script.js`,
|
|
6
|
+
"data-website-id": websiteId
|
|
7
|
+
};
|
|
8
|
+
if (options?.domains?.length) {
|
|
9
|
+
attrs["data-domains"] = options.domains.join(",");
|
|
10
|
+
}
|
|
11
|
+
if (options?.beforeSend) {
|
|
12
|
+
attrs["data-before-send"] = options.beforeSend;
|
|
13
|
+
}
|
|
14
|
+
if (options?.doNotTrack) {
|
|
15
|
+
attrs["data-do-not-track"] = "true";
|
|
16
|
+
}
|
|
17
|
+
if (options?.performance) {
|
|
18
|
+
attrs["data-performance"] = "true";
|
|
19
|
+
}
|
|
20
|
+
if (options?.tag) {
|
|
21
|
+
attrs["data-tag"] = options.tag;
|
|
22
|
+
}
|
|
23
|
+
return attrs;
|
|
24
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const UMAMI_ENDPOINTS = {
|
|
2
|
+
send: "/api/send"
|
|
3
|
+
};
|
|
4
|
+
export function coerceData(data) {
|
|
5
|
+
if (!data) return undefined;
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const [key, value] of Object.entries(data)){
|
|
8
|
+
if (value === undefined || value === null) continue;
|
|
9
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
10
|
+
out[key] = value;
|
|
11
|
+
} else {
|
|
12
|
+
out[key] = String(value);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function buildSendBody(input) {
|
|
18
|
+
const data = coerceData(input.data);
|
|
19
|
+
const payload = {
|
|
20
|
+
website: input.websiteId,
|
|
21
|
+
url: input.url ?? "/"
|
|
22
|
+
};
|
|
23
|
+
if (input.name) payload.name = input.name;
|
|
24
|
+
if (input.hostname) payload.hostname = input.hostname;
|
|
25
|
+
if (input.title) payload.title = input.title;
|
|
26
|
+
if (input.referrer) payload.referrer = input.referrer;
|
|
27
|
+
if (input.language) payload.language = input.language;
|
|
28
|
+
if (input.screen) payload.screen = input.screen;
|
|
29
|
+
if (data) payload.data = data;
|
|
30
|
+
if (input.userAgent) payload.userAgent = input.userAgent;
|
|
31
|
+
return {
|
|
32
|
+
type: "event",
|
|
33
|
+
payload
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const TOKEN_TTL_MS = 30 * 60 * 1000;
|
|
2
|
+
const WEBSITE_LIST_TTL_MS = 10 * 60 * 1000;
|
|
3
|
+
const PAGE_SIZE = 150;
|
|
4
|
+
export class UmamiReportingClient {
|
|
5
|
+
baseUrl;
|
|
6
|
+
username;
|
|
7
|
+
password;
|
|
8
|
+
apiKey;
|
|
9
|
+
sessionCookie;
|
|
10
|
+
teamId;
|
|
11
|
+
accessHeaders;
|
|
12
|
+
fetchImpl;
|
|
13
|
+
cachedToken = null;
|
|
14
|
+
tokenExpiry = 0;
|
|
15
|
+
cachedWebsites = null;
|
|
16
|
+
websitesExpiry = 0;
|
|
17
|
+
constructor(credentials){
|
|
18
|
+
this.baseUrl = credentials.apiUrl.replace(/\/+$/, "");
|
|
19
|
+
this.username = credentials.username;
|
|
20
|
+
this.password = credentials.password;
|
|
21
|
+
this.apiKey = credentials.apiKey;
|
|
22
|
+
this.sessionCookie = credentials.sessionCookie;
|
|
23
|
+
this.teamId = credentials.teamId;
|
|
24
|
+
this.accessHeaders = credentials.accessClientId && credentials.accessClientSecret ? {
|
|
25
|
+
"CF-Access-Client-Id": credentials.accessClientId,
|
|
26
|
+
"CF-Access-Client-Secret": credentials.accessClientSecret
|
|
27
|
+
} : {};
|
|
28
|
+
const injected = credentials.fetchImpl;
|
|
29
|
+
if (!injected && typeof globalThis.fetch !== "function") {
|
|
30
|
+
throw new Error("No fetch available. Pass fetchImpl to UmamiReportingClient.");
|
|
31
|
+
}
|
|
32
|
+
this.fetchImpl = injected ?? ((url, init)=>globalThis.fetch(url, init));
|
|
33
|
+
}
|
|
34
|
+
async getToken() {
|
|
35
|
+
if (this.cachedToken && Date.now() < this.tokenExpiry) return this.cachedToken;
|
|
36
|
+
const response = await this.fetchImpl(`${this.baseUrl}/api/auth/login`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
...this.accessHeaders
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
username: this.username,
|
|
44
|
+
email: this.username,
|
|
45
|
+
password: this.password
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Umami auth failed: ${response.status} ${response.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
const data = await response.json();
|
|
52
|
+
this.cachedToken = data.token;
|
|
53
|
+
this.tokenExpiry = Date.now() + TOKEN_TTL_MS;
|
|
54
|
+
return data.token;
|
|
55
|
+
}
|
|
56
|
+
async request(path, method = "GET", body) {
|
|
57
|
+
const url = this.apiKey ? `${this.baseUrl}${path.replace(/^\/api/, "")}` : `${this.baseUrl}${path}`;
|
|
58
|
+
const auth = this.apiKey ? {
|
|
59
|
+
"x-umami-api-key": this.apiKey
|
|
60
|
+
} : this.sessionCookie ? {
|
|
61
|
+
Cookie: this.sessionCookie
|
|
62
|
+
} : {
|
|
63
|
+
Authorization: `Bearer ${await this.getToken()}`
|
|
64
|
+
};
|
|
65
|
+
const response = await this.fetchImpl(url, {
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
...auth,
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
...this.accessHeaders
|
|
71
|
+
},
|
|
72
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
73
|
+
});
|
|
74
|
+
if (response.status === 401) {
|
|
75
|
+
this.cachedToken = null;
|
|
76
|
+
this.tokenExpiry = 0;
|
|
77
|
+
throw new Error("Umami auth token expired");
|
|
78
|
+
}
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new Error(`Umami API error: ${response.status} ${response.statusText} (${path})`);
|
|
81
|
+
}
|
|
82
|
+
if (method === "DELETE") return undefined;
|
|
83
|
+
return await response.json();
|
|
84
|
+
}
|
|
85
|
+
async getWebsites() {
|
|
86
|
+
if (this.cachedWebsites && Date.now() < this.websitesExpiry) return this.cachedWebsites;
|
|
87
|
+
const personal = await this.request(`/api/websites?pageSize=${PAGE_SIZE}`);
|
|
88
|
+
let teamSites = [];
|
|
89
|
+
if (this.teamId) {
|
|
90
|
+
const team = await this.request(`/api/teams/${this.teamId}/websites?pageSize=${PAGE_SIZE}`);
|
|
91
|
+
teamSites = team.data ?? [];
|
|
92
|
+
}
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const websites = [];
|
|
95
|
+
for (const site of [
|
|
96
|
+
...personal.data ?? [],
|
|
97
|
+
...teamSites
|
|
98
|
+
]){
|
|
99
|
+
if (seen.has(site.id)) continue;
|
|
100
|
+
seen.add(site.id);
|
|
101
|
+
websites.push(site);
|
|
102
|
+
}
|
|
103
|
+
this.cachedWebsites = websites;
|
|
104
|
+
this.websitesExpiry = Date.now() + WEBSITE_LIST_TTL_MS;
|
|
105
|
+
return websites;
|
|
106
|
+
}
|
|
107
|
+
async createWebsite(input) {
|
|
108
|
+
const site = await this.request("/api/websites", "POST", {
|
|
109
|
+
name: input.name,
|
|
110
|
+
domain: input.domain,
|
|
111
|
+
...input.teamId ? {
|
|
112
|
+
teamId: input.teamId
|
|
113
|
+
} : {}
|
|
114
|
+
});
|
|
115
|
+
this.cachedWebsites = null;
|
|
116
|
+
this.websitesExpiry = 0;
|
|
117
|
+
return site;
|
|
118
|
+
}
|
|
119
|
+
async getReports(websiteId) {
|
|
120
|
+
const page = await this.request(`/api/reports?websiteId=${websiteId}&pageSize=200`);
|
|
121
|
+
return Array.isArray(page) ? page : page.data;
|
|
122
|
+
}
|
|
123
|
+
async createReport(input) {
|
|
124
|
+
return this.request("/api/reports", "POST", input);
|
|
125
|
+
}
|
|
126
|
+
async deleteWebsite(websiteId) {
|
|
127
|
+
await this.request(`/api/websites/${websiteId}`, "DELETE");
|
|
128
|
+
this.cachedWebsites = null;
|
|
129
|
+
this.websitesExpiry = 0;
|
|
130
|
+
}
|
|
131
|
+
async getStats(websiteId, startAt, endAt) {
|
|
132
|
+
return this.request(`/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`);
|
|
133
|
+
}
|
|
134
|
+
async getDateRange(websiteId) {
|
|
135
|
+
return this.request(`/api/websites/${websiteId}/daterange`);
|
|
136
|
+
}
|
|
137
|
+
async getActive(websiteId) {
|
|
138
|
+
const data = await this.request(`/api/websites/${websiteId}/active`);
|
|
139
|
+
return data.visitors ?? 0;
|
|
140
|
+
}
|
|
141
|
+
async getMetrics(websiteId, type, startAt, endAt, limit = 20) {
|
|
142
|
+
return this.request(`/api/websites/${websiteId}/metrics?startAt=${startAt}&endAt=${endAt}&type=${type}&limit=${limit}`);
|
|
143
|
+
}
|
|
144
|
+
async getExpandedMetrics(websiteId, type, startAt, endAt, limit = 20) {
|
|
145
|
+
const raw = await this.request(`/api/websites/${websiteId}/metrics/expanded?startAt=${startAt}&endAt=${endAt}&type=${type}&limit=${limit}`);
|
|
146
|
+
return raw.map((entry)=>({
|
|
147
|
+
name: entry.name,
|
|
148
|
+
pageviews: Number(entry.pageviews),
|
|
149
|
+
visitors: entry.visitors,
|
|
150
|
+
visits: entry.visits,
|
|
151
|
+
bounces: entry.bounces,
|
|
152
|
+
totaltime: Number(entry.totaltime)
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
async getPageviews(websiteId, startAt, endAt, unit = "day", timezone = "UTC") {
|
|
156
|
+
return this.request(`/api/websites/${websiteId}/pageviews?startAt=${startAt}&endAt=${endAt}&unit=${unit}` + `&timezone=${encodeURIComponent(timezone)}`);
|
|
157
|
+
}
|
|
158
|
+
async getEvents(websiteId, startAt, endAt, pageSize = 50) {
|
|
159
|
+
return this.request(`/api/websites/${websiteId}/events?startAt=${startAt}&endAt=${endAt}&pageSize=${pageSize}`);
|
|
160
|
+
}
|
|
161
|
+
async getEventSeries(websiteId, startAt, endAt, unit = "day", timezone = "UTC") {
|
|
162
|
+
return this.request(`/api/websites/${websiteId}/events/series?startAt=${startAt}&endAt=${endAt}&unit=${unit}` + `&timezone=${encodeURIComponent(timezone)}`);
|
|
163
|
+
}
|
|
164
|
+
async getEventDataStats(websiteId, startAt, endAt) {
|
|
165
|
+
return this.request(`/api/websites/${websiteId}/event-data/stats?startAt=${startAt}&endAt=${endAt}`);
|
|
166
|
+
}
|
|
167
|
+
async getEventDataValues(websiteId, startAt, endAt, event, field) {
|
|
168
|
+
const params = new URLSearchParams({
|
|
169
|
+
startAt: String(startAt),
|
|
170
|
+
endAt: String(endAt),
|
|
171
|
+
event,
|
|
172
|
+
propertyName: field
|
|
173
|
+
});
|
|
174
|
+
return this.request(`/api/websites/${websiteId}/event-data/values?${params.toString()}`);
|
|
175
|
+
}
|
|
176
|
+
async getSessions(websiteId, startAt, endAt, pageSize = 50) {
|
|
177
|
+
return this.request(`/api/websites/${websiteId}/sessions?startAt=${startAt}&endAt=${endAt}&pageSize=${pageSize}`);
|
|
178
|
+
}
|
|
179
|
+
async resolveSiteId(siteArg) {
|
|
180
|
+
if (/^[0-9a-f-]{36}$/i.test(siteArg)) return siteArg;
|
|
181
|
+
const websites = await this.getWebsites();
|
|
182
|
+
const lower = siteArg.toLowerCase();
|
|
183
|
+
const byDomain = websites.find((w)=>w.domain.toLowerCase() === lower);
|
|
184
|
+
if (byDomain) return byDomain.id;
|
|
185
|
+
const byPartial = websites.find((w)=>w.domain.toLowerCase().includes(lower) || w.name.toLowerCase().includes(lower));
|
|
186
|
+
if (byPartial) return byPartial.id;
|
|
187
|
+
throw new Error(`No website found matching "${siteArg}". Run \`umami websites\` to see available sites.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const defaultRateLimits = {
|
|
2
|
+
enabled: true,
|
|
3
|
+
storage: "memory",
|
|
4
|
+
window: 60,
|
|
5
|
+
max: 100,
|
|
6
|
+
customRules: {
|
|
7
|
+
"/email-otp/send-verification-otp": {
|
|
8
|
+
window: 60,
|
|
9
|
+
max: 3
|
|
10
|
+
},
|
|
11
|
+
"/sign-in/email-otp": {
|
|
12
|
+
window: 10,
|
|
13
|
+
max: 3
|
|
14
|
+
},
|
|
15
|
+
"/email-otp/verify-email": {
|
|
16
|
+
window: 10,
|
|
17
|
+
max: 3
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function defaultTrustedOrigins(siteUrl) {
|
|
2
|
+
const origins = new Set([
|
|
3
|
+
siteUrl
|
|
4
|
+
]);
|
|
5
|
+
const extra = process.env.TRUSTED_ORIGINS;
|
|
6
|
+
if (extra) {
|
|
7
|
+
for (const origin of extra.split(",")){
|
|
8
|
+
const trimmed = origin.trim();
|
|
9
|
+
if (trimmed) origins.add(trimmed);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return [
|
|
13
|
+
...origins
|
|
14
|
+
];
|
|
15
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const DEFAULT_PALETTE = {
|
|
2
|
+
background: "#f4f4f5",
|
|
3
|
+
surface: "#ffffff",
|
|
4
|
+
ink: "#18181b",
|
|
5
|
+
muted: "#71717a",
|
|
6
|
+
subtle: "#a1a1aa",
|
|
7
|
+
rule: "#ececec",
|
|
8
|
+
radius: 14
|
|
9
|
+
};
|
|
10
|
+
export const FONT_STACK = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif";
|
|
11
|
+
export const MONO_STACK = "'SF Mono',Monaco,'Cascadia Code','Roboto Mono',Consolas,'Courier New',monospace";
|
|
12
|
+
export function resolvePalette(brand) {
|
|
13
|
+
return {
|
|
14
|
+
...DEFAULT_PALETTE,
|
|
15
|
+
...brand.palette
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function escapeHtml(value) {
|
|
19
|
+
return value.replace(/[&<>"']/g, (c)=>`&#${c.charCodeAt(0)};`);
|
|
20
|
+
}
|
|
21
|
+
export function hexToRgb(hex) {
|
|
22
|
+
const match = /^#?([a-f\d]{3}|[a-f\d]{6})$/i.exec(hex.trim());
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
const full = match[1].length === 3 ? [
|
|
25
|
+
...match[1]
|
|
26
|
+
].map((c)=>c + c).join("") : match[1];
|
|
27
|
+
const n = Number.parseInt(full, 16);
|
|
28
|
+
return [
|
|
29
|
+
n >> 16 & 255,
|
|
30
|
+
n >> 8 & 255,
|
|
31
|
+
n & 255
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
export function renderEmail(brand, content) {
|
|
35
|
+
const { name, url, color = "#6366f1", logoUrl, tagline } = brand;
|
|
36
|
+
const p = resolvePalette(brand);
|
|
37
|
+
const align = content.align ?? "center";
|
|
38
|
+
const domain = url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
39
|
+
const year = new Date().getFullYear();
|
|
40
|
+
const row = (html, padding, rowAlign = align)=>`<tr><td align="${rowAlign}" style="padding:${padding};text-align:${rowAlign};">${html}</td></tr>`;
|
|
41
|
+
const brandRow = row(`<table role="presentation" cellspacing="0" cellpadding="0"${align === "center" ? ' align="center"' : ""}><tr>
|
|
42
|
+
${logoUrl ? `<td style="padding-right:9px;vertical-align:middle;"><img src="${escapeHtml(logoUrl)}" alt="" width="26" height="26" style="display:block;border:0;border-radius:${Math.min(p.radius, 6)}px;" /></td>` : ""}
|
|
43
|
+
<td style="vertical-align:middle;"><span style="font-size:15px;font-weight:700;color:${p.ink};letter-spacing:-0.01em;">${escapeHtml(name)}</span></td>
|
|
44
|
+
</tr></table>`, "32px 32px 20px");
|
|
45
|
+
const action = content.action ? row(`<table role="presentation" cellspacing="0" cellpadding="0"${align === "center" ? ' align="center"' : ""}><tr><td style="background-color:${color};border-radius:${Math.min(p.radius, 8)}px;">
|
|
46
|
+
<a href="${escapeHtml(content.action.url)}" style="display:inline-block;padding:11px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${escapeHtml(content.action.label)}</a>
|
|
47
|
+
</td></tr></table>`, "4px 32px 24px") : "";
|
|
48
|
+
const html = `<!DOCTYPE html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><meta name="color-scheme" content="light"><meta name="supported-color-schemes" content="light"><title>${escapeHtml(content.heading)}</title></head>
|
|
51
|
+
<body style="margin:0;padding:0;background-color:${p.background};font-family:${FONT_STACK};color:${p.ink};-webkit-font-smoothing:antialiased;">
|
|
52
|
+
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(content.preview)}</div>
|
|
53
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:${p.background};">
|
|
54
|
+
<tr><td align="center" style="padding:40px 16px 32px;">
|
|
55
|
+
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width:520px;width:100%;background-color:${p.surface};border-radius:${p.radius}px;border:1px solid ${p.rule};">
|
|
56
|
+
${brandRow}
|
|
57
|
+
${row(`<h1 style="margin:0;font-size:18px;line-height:1.35;font-weight:600;color:${p.ink};">${escapeHtml(content.heading)}</h1>`, "0 32px")}
|
|
58
|
+
${content.intro ? row(`<p style="margin:0;font-size:14px;line-height:1.55;color:${p.muted};">${escapeHtml(content.intro)}</p>`, "8px 32px 0") : ""}
|
|
59
|
+
${content.body ? row(content.body.html, "24px 32px") : row("", "12px 32px 0")}
|
|
60
|
+
${action}
|
|
61
|
+
${content.note ? row(`<p style="margin:0;padding-top:16px;border-top:1px solid ${p.rule};font-size:12px;line-height:1.6;color:${p.subtle};">${escapeHtml(content.note)}</p>`, "0 32px 28px") : row("", "0 0 8px")}
|
|
62
|
+
</table>
|
|
63
|
+
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width:520px;width:100%;">
|
|
64
|
+
<tr><td align="center" style="padding:18px 32px 0;">
|
|
65
|
+
<p style="margin:0;font-size:11px;color:${p.subtle};">© ${year} ${escapeHtml(name)}${tagline ? ` · ${escapeHtml(tagline)}` : ""} · <a href="${escapeHtml(url)}" style="color:${p.subtle};text-decoration:underline;">${escapeHtml(domain)}</a></p>
|
|
66
|
+
</td></tr>
|
|
67
|
+
</table>
|
|
68
|
+
</td></tr>
|
|
69
|
+
</table>
|
|
70
|
+
</body>
|
|
71
|
+
</html>`;
|
|
72
|
+
const text = [
|
|
73
|
+
content.heading,
|
|
74
|
+
content.intro,
|
|
75
|
+
content.body?.text,
|
|
76
|
+
content.action ? `${content.action.label}: ${content.action.url}` : undefined,
|
|
77
|
+
content.note,
|
|
78
|
+
`---\n${name} · ${domain}`
|
|
79
|
+
].filter((part)=>Boolean(part)).join("\n\n");
|
|
80
|
+
return {
|
|
81
|
+
html,
|
|
82
|
+
text
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function createResendSender(apiKey, from) {
|
|
2
|
+
return async (to, subject, html, text, options = {})=>{
|
|
3
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
4
|
+
method: "POST",
|
|
5
|
+
headers: {
|
|
6
|
+
Authorization: `Bearer ${apiKey}`,
|
|
7
|
+
"Content-Type": "application/json"
|
|
8
|
+
},
|
|
9
|
+
body: JSON.stringify({
|
|
10
|
+
from,
|
|
11
|
+
to: [
|
|
12
|
+
to
|
|
13
|
+
],
|
|
14
|
+
subject,
|
|
15
|
+
html,
|
|
16
|
+
text,
|
|
17
|
+
...options.replyTo ? {
|
|
18
|
+
reply_to: options.replyTo
|
|
19
|
+
} : {},
|
|
20
|
+
...options.headers ? {
|
|
21
|
+
headers: options.headers
|
|
22
|
+
} : {}
|
|
23
|
+
})
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const body = await res.text().catch(()=>"");
|
|
27
|
+
throw new Error(`Resend failed (${res.status}): ${body}`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { escapeHtml, hexToRgb, FONT_STACK, renderEmail, resolvePalette } from "./layout.js";
|
|
2
|
+
const LABELS = {
|
|
3
|
+
"sign-in": {
|
|
4
|
+
subject: (_otp, app)=>`${app} sign-in code`,
|
|
5
|
+
heading: "Sign-in code",
|
|
6
|
+
instruction: "Enter this code to sign in to your account."
|
|
7
|
+
},
|
|
8
|
+
"email-verification": {
|
|
9
|
+
subject: (_otp, app)=>`Verify your ${app} email`,
|
|
10
|
+
heading: "Verify your email",
|
|
11
|
+
instruction: "Enter this code to verify your email address."
|
|
12
|
+
},
|
|
13
|
+
"reset-password": {
|
|
14
|
+
subject: (_otp, app)=>`Reset your ${app} password`,
|
|
15
|
+
heading: "Reset your password",
|
|
16
|
+
instruction: "Enter this code to reset your password."
|
|
17
|
+
},
|
|
18
|
+
"forget-password": {
|
|
19
|
+
subject: (_otp, app)=>`Reset your ${app} password`,
|
|
20
|
+
heading: "Reset your password",
|
|
21
|
+
instruction: "Enter this code to reset your password."
|
|
22
|
+
},
|
|
23
|
+
"change-email": {
|
|
24
|
+
subject: (_otp, app)=>`Confirm your new ${app} email`,
|
|
25
|
+
heading: "Confirm email change",
|
|
26
|
+
instruction: "Enter this code to confirm your new email address."
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export function buildOtpEmail(brand) {
|
|
30
|
+
return (otp, type = "sign-in")=>{
|
|
31
|
+
const { name, color = "#6366f1", expirationMinutes = 5 } = brand;
|
|
32
|
+
const p = resolvePalette(brand);
|
|
33
|
+
const label = LABELS[type];
|
|
34
|
+
const rgb = hexToRgb(color);
|
|
35
|
+
const cellBorder = rgb ? `rgba(${rgb.join(",")},0.35)` : color;
|
|
36
|
+
const cellBg = rgb ? `rgba(${rgb.join(",")},0.07)` : p.background;
|
|
37
|
+
const codeCell = `<td align="center" valign="middle" style="padding:12px 22px;border:1.5px solid ${cellBorder};border-radius:${Math.min(p.radius, 8)}px;background-color:${cellBg};">
|
|
38
|
+
<span style="font-size:30px;font-weight:500;letter-spacing:6px;color:${p.ink};font-family:${FONT_STACK};font-variant-numeric:tabular-nums;-webkit-user-select:all;user-select:all;">${escapeHtml(otp)}</span>
|
|
39
|
+
</td>`;
|
|
40
|
+
const { html, text } = renderEmail(brand, {
|
|
41
|
+
preview: `${otp}: ${label.instruction}`,
|
|
42
|
+
heading: label.heading,
|
|
43
|
+
intro: label.instruction,
|
|
44
|
+
body: {
|
|
45
|
+
html: `<table role="presentation" cellspacing="0" cellpadding="0" align="center"><tr>${codeCell}</tr></table>
|
|
46
|
+
<p style="margin:18px 0 0;font-size:12.5px;color:${p.subtle};">Expires in ${expirationMinutes} minutes</p>`,
|
|
47
|
+
text: `Your code: ${otp}\n\nThis code expires in ${expirationMinutes} minutes.`
|
|
48
|
+
},
|
|
49
|
+
note: "Didn't request this? Ignore this email. Never share this code."
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
subject: label.subject(otp, name),
|
|
53
|
+
html,
|
|
54
|
+
text
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
}
|
|
File without changes
|
package/dist/cache.js
ADDED
package/dist/cn.js
ADDED