@tdhftw/emdash-plugin-umami 0.0.0-stage → 0.1.3

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/dist/index.js ADDED
@@ -0,0 +1,827 @@
1
+ import { definePlugin } from "emdash";
2
+ import { z } from "astro/zod";
3
+ //#region src/cache.ts
4
+ /**
5
+ * Returns a cached value younger than `maxAgeMs`, otherwise loads a fresh one. If loading fails and
6
+ * any cached copy exists, that copy is returned as stale instead of failing the request.
7
+ */
8
+ async function cached(kv, key, maxAgeMs, load, now = Date.now()) {
9
+ const entry = await kv.get(key);
10
+ if (entry && now - entry.at < maxAgeMs) return {
11
+ value: entry.value,
12
+ stale: false
13
+ };
14
+ try {
15
+ const value = await load();
16
+ await kv.set(key, {
17
+ at: now,
18
+ value
19
+ });
20
+ return {
21
+ value,
22
+ stale: false
23
+ };
24
+ } catch (error) {
25
+ if (entry) return {
26
+ value: entry.value,
27
+ stale: true
28
+ };
29
+ throw error;
30
+ }
31
+ }
32
+ //#endregion
33
+ //#region src/shared/constants.ts
34
+ /** Plugin id. One URL segment: routes mount at /_emdash/api/plugins/umami/<route>. */
35
+ const PLUGIN_ID = "umami";
36
+ /** Keep in sync with package.json; test/plugin.test.ts enforces it. */
37
+ const PLUGIN_VERSION = "0.1.3";
38
+ const PACKAGE_NAME = "@tdhftw/emdash-plugin-umami";
39
+ //#endregion
40
+ //#region src/settings.ts
41
+ /**
42
+ * Settings form schema. EmDash renders it under Plugins → Umami → settings; `secret` fields are
43
+ * encrypted with EMDASH_ENCRYPTION_KEY and never sent back to the browser. Defaults only fill the
44
+ * form, they are not persisted, so readSettings() applies the same fallbacks.
45
+ */
46
+ const settingsSchema = {
47
+ hosting: {
48
+ type: "select",
49
+ label: "Hosting",
50
+ description: "Tip: Analytics → Settings in the sidebar has a guided setup with a connection test and a website picker.",
51
+ options: [{
52
+ value: "self-hosted",
53
+ label: "Self-hosted Umami"
54
+ }, ...[]],
55
+ default: "self-hosted"
56
+ },
57
+ baseUrl: {
58
+ type: "url",
59
+ label: "Umami URL (self-hosted)",
60
+ placeholder: "https://stats.example.com"
61
+ },
62
+ region: {
63
+ type: "select",
64
+ label: "Region (Umami Cloud, coming in 0.2.0)",
65
+ options: [
66
+ {
67
+ value: "default",
68
+ label: "Account default"
69
+ },
70
+ {
71
+ value: "us",
72
+ label: "United States"
73
+ },
74
+ {
75
+ value: "eu",
76
+ label: "European Union"
77
+ }
78
+ ],
79
+ default: "default"
80
+ },
81
+ authMode: {
82
+ type: "select",
83
+ label: "Sign in with",
84
+ options: [{
85
+ value: "apiKey",
86
+ label: "API key"
87
+ }, {
88
+ value: "password",
89
+ label: "Username and password (older self-hosted Umami)"
90
+ }],
91
+ default: "apiKey"
92
+ },
93
+ apiKey: {
94
+ type: "secret",
95
+ label: "API key",
96
+ description: "Umami → Settings → API keys."
97
+ },
98
+ username: {
99
+ type: "string",
100
+ label: "Username (password sign-in)"
101
+ },
102
+ password: {
103
+ type: "secret",
104
+ label: "Password (password sign-in)"
105
+ },
106
+ websiteId: {
107
+ type: "string",
108
+ label: "Website ID",
109
+ description: "Umami → Settings → Websites → Edit. A UUID."
110
+ },
111
+ injectScript: {
112
+ type: "boolean",
113
+ label: "Add the Umami tracker to public pages",
114
+ default: false
115
+ },
116
+ clientRouter: {
117
+ type: "boolean",
118
+ label: "The site uses Astro's <ClientRouter /> (view transitions)",
119
+ description: "Counts every page change, including back and forward, once.",
120
+ default: false
121
+ },
122
+ scriptUrl: {
123
+ type: "url",
124
+ label: "Custom tracker script URL",
125
+ description: "Optional. Default: <Umami URL>/script.js.",
126
+ placeholder: "https://stats.example.com/script.js"
127
+ }
128
+ };
129
+ function str(value) {
130
+ if (typeof value !== "string") return null;
131
+ const trimmed = value.trim();
132
+ return trimmed.length > 0 ? trimmed : null;
133
+ }
134
+ function oneOf(value, allowed, fallback) {
135
+ return typeof value === "string" && allowed.includes(value) ? value : fallback;
136
+ }
137
+ /** Strips trailing slashes and a trailing /api so users can paste either form. */
138
+ function normalizeBaseUrl(value) {
139
+ if (!value) return null;
140
+ let url;
141
+ try {
142
+ url = new URL(value);
143
+ } catch {
144
+ return null;
145
+ }
146
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
147
+ const path = url.pathname.replace(/\/+$/, "").replace(/\/api$/, "");
148
+ return `${url.origin}${path}`;
149
+ }
150
+ async function readSettings(settings) {
151
+ const keys = Object.keys(settingsSchema);
152
+ const values = await Promise.all(keys.map((key) => settings.get(key)));
153
+ const raw = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
154
+ const hosting = oneOf(raw.hosting, ["self-hosted"], "self-hosted");
155
+ return {
156
+ hosting,
157
+ baseUrl: normalizeBaseUrl(str(raw.baseUrl)),
158
+ region: oneOf(raw.region, [
159
+ "default",
160
+ "us",
161
+ "eu"
162
+ ], "default"),
163
+ authMode: hosting === "cloud" ? "apiKey" : oneOf(raw.authMode, ["apiKey", "password"], "apiKey"),
164
+ apiKey: str(raw.apiKey),
165
+ username: str(raw.username),
166
+ password: typeof raw.password === "string" && raw.password.length > 0 ? raw.password : null,
167
+ websiteId: str(raw.websiteId),
168
+ injectScript: raw.injectScript === true,
169
+ clientRouter: raw.clientRouter === true,
170
+ scriptUrl: normalizeScriptUrl(str(raw.scriptUrl))
171
+ };
172
+ }
173
+ function normalizeScriptUrl(value) {
174
+ if (!value) return null;
175
+ try {
176
+ const url = new URL(value);
177
+ return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ /** Names of settings that must be filled in before Umami can be queried. */
183
+ function missingSettings(s) {
184
+ const missing = [];
185
+ if (s.hosting === "self-hosted" && !s.baseUrl) missing.push("baseUrl");
186
+ if (s.authMode === "apiKey" && !s.apiKey) missing.push("apiKey");
187
+ if (s.authMode === "password") {
188
+ if (!s.username) missing.push("username");
189
+ if (!s.password) missing.push("password");
190
+ }
191
+ if (!s.websiteId) missing.push("websiteId");
192
+ return missing;
193
+ }
194
+ /** Where a person views this site's stats in Umami. */
195
+ function dashboardUrl(s) {
196
+ const origin = s.hosting === "cloud" ? "https://cloud.umami.is" : s.baseUrl;
197
+ if (!origin) return null;
198
+ return s.websiteId ? `${origin}/websites/${encodeURIComponent(s.websiteId)}` : origin;
199
+ }
200
+ //#endregion
201
+ //#region src/shared/stats.ts
202
+ /** Stats shapes shared by the server routes and the admin UI. */
203
+ const RANGES = [
204
+ "24h",
205
+ "7d",
206
+ "30d",
207
+ "90d"
208
+ ];
209
+ const METRIC_TYPES = [
210
+ "path",
211
+ "referrer",
212
+ "country",
213
+ "browser",
214
+ "os",
215
+ "device",
216
+ "event"
217
+ ];
218
+ //#endregion
219
+ //#region src/umami/client.ts
220
+ /** Error with a stable code for the UI. Messages never contain credentials. */
221
+ var UmamiError = class extends Error {
222
+ code;
223
+ status;
224
+ constructor(code, message, status, options) {
225
+ super(message, options);
226
+ this.code = code;
227
+ this.status = status;
228
+ this.name = "UmamiError";
229
+ }
230
+ };
231
+ /** Server-log description of a low-level failure. Fetch errors carry URLs, never our headers. */
232
+ function describeCause(cause) {
233
+ if (cause instanceof Error) {
234
+ const inner = cause.cause instanceof Error ? ` (${cause.cause.name}: ${cause.cause.message})` : "";
235
+ return `${cause.name}: ${cause.message}${inner}`;
236
+ }
237
+ return String(cause);
238
+ }
239
+ const TOKEN_KEY = "state:token";
240
+ const TIMEOUT_MS = 1e4;
241
+ /** API root: `<baseUrl>/api` when self-hosted, `https://api.umami.is/v1[/region]` on Umami Cloud. */
242
+ function apiBase(s) {
243
+ if (s.hosting === "cloud") return s.region === "default" ? "https://api.umami.is/v1" : `https://api.umami.is/v1/${s.region}`;
244
+ return s.baseUrl ? `${s.baseUrl}/api` : null;
245
+ }
246
+ function describeStatus(status, what) {
247
+ if (status === 401) return new UmamiError("unauthorized", "Umami rejected the credentials. Check the API key, or the username and password.", status);
248
+ if (status === 403) return new UmamiError("forbidden", `This Umami account is not allowed to read ${what}.`, status);
249
+ if (status === 404) return new UmamiError("not-found", `Umami could not find ${what}. Check the Umami URL and the website.`, status);
250
+ if (status === 429) return new UmamiError("rate-limited", "Umami is rate limiting requests. Try again in a minute.", status);
251
+ return new UmamiError("upstream", `Umami answered with HTTP ${status} for ${what}.`, status);
252
+ }
253
+ function createUmamiClient(s, deps) {
254
+ const base = apiBase(s);
255
+ if (!base) throw new UmamiError("not-configured", "Add the Umami URL in the Umami settings.");
256
+ if (s.authMode === "apiKey" && !s.apiKey) throw new UmamiError("not-configured", "Add an Umami API key in the Umami settings.");
257
+ if (s.authMode === "password" && (!s.username || !s.password)) throw new UmamiError("not-configured", "Add the Umami username and password in the Umami settings.");
258
+ async function send(url, init, what) {
259
+ try {
260
+ return await deps.fetch(url, {
261
+ ...init,
262
+ signal: AbortSignal.timeout(TIMEOUT_MS)
263
+ });
264
+ } catch (cause) {
265
+ throw new UmamiError("unreachable", `Could not reach Umami at ${new URL(url).origin} while loading ${what}.`, void 0, { cause });
266
+ }
267
+ }
268
+ const scope = `${base}|${s.username ?? ""}`;
269
+ async function login() {
270
+ const res = await send(`${base}/auth/login`, {
271
+ method: "POST",
272
+ headers: {
273
+ "Content-Type": "application/json",
274
+ Accept: "application/json"
275
+ },
276
+ body: JSON.stringify({
277
+ username: s.username,
278
+ password: s.password
279
+ })
280
+ }, "the login");
281
+ if (!res.ok) throw describeStatus(res.status === 400 ? 401 : res.status, "the login");
282
+ const body = await res.json().catch(() => null);
283
+ if (!body || typeof body.token !== "string") throw new UmamiError("upstream", "Umami's login answer had no token. Is this an Umami server?");
284
+ await deps.kv.set(TOKEN_KEY, {
285
+ token: body.token,
286
+ scope
287
+ });
288
+ return body.token;
289
+ }
290
+ async function bearer(forceLogin) {
291
+ if (s.authMode === "apiKey") return s.apiKey;
292
+ if (!forceLogin) {
293
+ const cached = await deps.kv.get(TOKEN_KEY);
294
+ if (cached && cached.scope === scope) return cached.token;
295
+ }
296
+ return login();
297
+ }
298
+ return { async get(path, query = {}, what = "the data") {
299
+ const url = new URL(`${base}${path}`);
300
+ for (const [key, value] of Object.entries(query)) if (value !== void 0) url.searchParams.set(key, String(value));
301
+ const request = async (forceLogin) => send(url.toString(), { headers: {
302
+ Authorization: `Bearer ${await bearer(forceLogin)}`,
303
+ Accept: "application/json"
304
+ } }, what);
305
+ let res = await request(false);
306
+ if (res.status === 401 && s.authMode === "password") {
307
+ await deps.kv.delete(TOKEN_KEY);
308
+ res = await request(true);
309
+ }
310
+ if (!res.ok) throw describeStatus(res.status, what);
311
+ try {
312
+ return await res.json();
313
+ } catch {
314
+ throw new UmamiError("upstream", `Umami's answer for ${what} was not JSON. Is the Umami URL right?`);
315
+ }
316
+ } };
317
+ }
318
+ function toWebsite(w) {
319
+ if (typeof w.id !== "string") return null;
320
+ return {
321
+ id: w.id,
322
+ name: typeof w.name === "string" && w.name ? w.name : w.id,
323
+ domain: typeof w.domain === "string" && w.domain ? w.domain : null
324
+ };
325
+ }
326
+ /**
327
+ * Websites the credentials can read, sorted by name: the user's own and those of teams they own or
328
+ * manage (`/websites?includeTeams`), plus every team they belong to in any role (`/teams/{id}/websites`),
329
+ * which covers sites shared with a plain team member.
330
+ */
331
+ async function listWebsites(client) {
332
+ const [own, teams] = await Promise.all([client.get("/websites", {
333
+ includeTeams: true,
334
+ pageSize: 200
335
+ }, "the website list"), client.get("/teams", { pageSize: 100 }, "the team list").catch(() => ({ data: [] }))]);
336
+ const teamIds = (teams.data ?? []).map((t) => t.id).filter((id) => typeof id === "string");
337
+ const teamSites = await Promise.all(teamIds.map((id) => client.get(`/teams/${encodeURIComponent(id)}/websites`, { pageSize: 200 }, "a team's websites").catch(() => ({ data: [] }))));
338
+ const byId = /* @__PURE__ */ new Map();
339
+ for (const raw of [...own.data ?? [], ...teamSites.flatMap((page) => page.data ?? [])]) {
340
+ const site = toWebsite(raw);
341
+ if (site && !byId.has(site.id)) byId.set(site.id, site);
342
+ }
343
+ return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
344
+ }
345
+ /** One website by id, or null when Umami says it does not exist or is not visible. */
346
+ async function getWebsite(client, id) {
347
+ try {
348
+ return toWebsite(await client.get(`/websites/${encodeURIComponent(id)}`, {}, "the website"));
349
+ } catch (error) {
350
+ if (error instanceof UmamiError && (error.code === "not-found" || error.code === "forbidden")) return null;
351
+ throw error;
352
+ }
353
+ }
354
+ //#endregion
355
+ //#region src/umami/stats.ts
356
+ const HOUR = 36e5;
357
+ const DAY = 24 * HOUR;
358
+ /** Rolling windows ending now; hours for the last day, days otherwise. */
359
+ function windowFor(range, now) {
360
+ const days = {
361
+ "24h": 1,
362
+ "7d": 7,
363
+ "30d": 30,
364
+ "90d": 90
365
+ }[range];
366
+ return {
367
+ range,
368
+ unit: range === "24h" ? "hour" : "day",
369
+ startAt: now - days * DAY + 1,
370
+ endAt: now
371
+ };
372
+ }
373
+ function isValidTimeZone(tz) {
374
+ try {
375
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
376
+ return true;
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+ function num(value) {
382
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
383
+ return Number.isFinite(n) ? n : 0;
384
+ }
385
+ function toTotals(raw) {
386
+ const r = raw ?? {};
387
+ return {
388
+ pageviews: num(r.pageviews),
389
+ visitors: num(r.visitors),
390
+ visits: num(r.visits),
391
+ bounces: num(r.bounces),
392
+ totaltime: num(r.totaltime)
393
+ };
394
+ }
395
+ const formatters = /* @__PURE__ */ new Map();
396
+ function formatter(tz) {
397
+ let f = formatters.get(tz);
398
+ if (!f) {
399
+ f = new Intl.DateTimeFormat("en-CA", {
400
+ timeZone: tz,
401
+ year: "numeric",
402
+ month: "2-digit",
403
+ day: "2-digit",
404
+ hour: "2-digit",
405
+ hourCycle: "h23"
406
+ });
407
+ formatters.set(tz, f);
408
+ }
409
+ return f;
410
+ }
411
+ /** Wall-clock parts of an instant in a time zone. */
412
+ function partsIn(t, tz) {
413
+ const parts = Object.fromEntries(formatter(tz).formatToParts(new Date(t)).map((p) => [p.type, p.value]));
414
+ return {
415
+ date: `${parts.year}-${parts.month}-${parts.day}`,
416
+ hour: parts.hour
417
+ };
418
+ }
419
+ function bucketLabel(t, unit, tz) {
420
+ const p = partsIn(t, tz);
421
+ return unit === "day" ? p.date : `${p.date} ${p.hour}:00`;
422
+ }
423
+ /**
424
+ * Umami returns buckets as wall-clock strings in the requested zone ("2026-09-28 14:00:00", or an
425
+ * ISO-like variant) and omits empty ones. Normalize to our label format.
426
+ */
427
+ function labelFromUmami(x, unit) {
428
+ const clean = x.replace("T", " ");
429
+ return unit === "day" ? clean.slice(0, 10) : `${clean.slice(0, 13)}:00`;
430
+ }
431
+ /** Every bucket in the window, oldest first, so charts show zero days instead of skipping them. */
432
+ function buckets(win, tz) {
433
+ const step = HOUR;
434
+ const seen = /* @__PURE__ */ new Map();
435
+ for (let t = Math.floor(win.startAt / HOUR) * HOUR; t <= win.endAt; t += step) {
436
+ const label = bucketLabel(t, win.unit, tz);
437
+ if (!seen.has(label)) seen.set(label, t);
438
+ }
439
+ const end = bucketLabel(win.endAt, win.unit, tz);
440
+ if (!seen.has(end)) seen.set(end, win.endAt);
441
+ return [...seen.entries()].map(([label, t]) => ({
442
+ label,
443
+ t
444
+ }));
445
+ }
446
+ function mergeSeries(win, tz, pageviews, sessions) {
447
+ const index = (rows) => {
448
+ const m = /* @__PURE__ */ new Map();
449
+ for (const row of rows ?? []) {
450
+ if (typeof row.x !== "string") continue;
451
+ const key = labelFromUmami(row.x, win.unit);
452
+ m.set(key, (m.get(key) ?? 0) + num(row.y));
453
+ }
454
+ return m;
455
+ };
456
+ const pv = index(pageviews);
457
+ const vis = index(sessions);
458
+ return buckets(win, tz).map(({ label, t }) => ({
459
+ label,
460
+ t,
461
+ pageviews: pv.get(label) ?? 0,
462
+ visitors: vis.get(label) ?? 0
463
+ }));
464
+ }
465
+ function toRows(raw, limit) {
466
+ if (!Array.isArray(raw)) return [];
467
+ return raw.map((row) => ({
468
+ label: row.x == null || row.x === "" ? "(none)" : String(row.x),
469
+ value: num(row.y)
470
+ })).filter((row) => row.value > 0).slice(0, limit);
471
+ }
472
+ const TOP_LIMIT = 10;
473
+ async function fetchOverview(client, websiteId, range, timezone, now = Date.now()) {
474
+ const win = windowFor(range, now);
475
+ const id = encodeURIComponent(websiteId);
476
+ const q = {
477
+ startAt: win.startAt,
478
+ endAt: win.endAt,
479
+ timezone
480
+ };
481
+ const length = win.endAt - win.startAt;
482
+ const [stats, prev, series, ...metrics] = await Promise.all([
483
+ client.get(`/websites/${id}/stats`, q, "the summary"),
484
+ client.get(`/websites/${id}/stats`, {
485
+ ...q,
486
+ startAt: win.startAt - length - 1,
487
+ endAt: win.startAt - 1
488
+ }, "the previous period"),
489
+ client.get(`/websites/${id}/pageviews`, {
490
+ ...q,
491
+ unit: win.unit
492
+ }, "the chart"),
493
+ ...METRIC_TYPES.map((type) => client.get(`/websites/${id}/metrics`, {
494
+ ...q,
495
+ type,
496
+ limit: TOP_LIMIT
497
+ }, `the top ${type} list`))
498
+ ]);
499
+ return {
500
+ websiteId,
501
+ range,
502
+ unit: win.unit,
503
+ timezone,
504
+ startAt: win.startAt,
505
+ endAt: win.endAt,
506
+ totals: toTotals(stats),
507
+ previous: toTotals(prev),
508
+ series: mergeSeries(win, timezone, series.pageviews ?? [], series.sessions ?? []),
509
+ top: Object.fromEntries(METRIC_TYPES.map((type, i) => [type, toRows(metrics[i], TOP_LIMIT)])),
510
+ fetchedAt: now,
511
+ stale: false
512
+ };
513
+ }
514
+ async function fetchActive(client, websiteId, now = Date.now()) {
515
+ return {
516
+ visitors: num((await client.get(`/websites/${encodeURIComponent(websiteId)}/active`, {}, "active visitors"))?.visitors),
517
+ fetchedAt: now
518
+ };
519
+ }
520
+ //#endregion
521
+ //#region src/routes/stats.ts
522
+ const overviewInput = z.object({
523
+ range: z.enum(RANGES).default("7d"),
524
+ tz: z.string().max(64).optional().transform((tz) => tz && isValidTimeZone(tz) ? tz : "UTC")
525
+ });
526
+ /** 24h data moves quickly; longer ranges barely change minute to minute. */
527
+ const OVERVIEW_MAX_AGE = {
528
+ "24h": 6e4,
529
+ "7d": 3e5,
530
+ "30d": 6e5,
531
+ "90d": 9e5
532
+ };
533
+ const ACTIVE_MAX_AGE = 2e4;
534
+ async function withClient(ctx, run) {
535
+ try {
536
+ const s = await readSettings(ctx.settings);
537
+ if (!s.websiteId) throw new UmamiError("not-configured", "Choose the Umami website in the plugin settings.");
538
+ const http = ctx.http;
539
+ if (!http) throw new UmamiError("unreachable", "EmDash did not grant this plugin network access.");
540
+ return {
541
+ ok: true,
542
+ data: await run(createUmamiClient(s, {
543
+ fetch: (url, init) => http.fetch(url, init),
544
+ kv: ctx.kv
545
+ }), s.websiteId)
546
+ };
547
+ } catch (error) {
548
+ if (error instanceof UmamiError) {
549
+ if (error.cause) ctx.log.warn("Umami request failed", {
550
+ code: error.code,
551
+ cause: describeCause(error.cause)
552
+ });
553
+ return {
554
+ ok: false,
555
+ code: error.code,
556
+ message: error.message
557
+ };
558
+ }
559
+ ctx.log.error("Umami request failed", { error: error instanceof Error ? error.message : String(error) });
560
+ return {
561
+ ok: false,
562
+ code: "upstream",
563
+ message: "Loading statistics failed unexpectedly. See the server log."
564
+ };
565
+ }
566
+ }
567
+ function overview(ctx) {
568
+ const { range, tz } = ctx.input;
569
+ return withClient(ctx, async (client, websiteId) => {
570
+ const hit = await cached(ctx.kv, `cache:overview:${websiteId}:${range}:${tz}`, OVERVIEW_MAX_AGE[range], () => fetchOverview(client, websiteId, range, tz));
571
+ return {
572
+ ...hit.value,
573
+ stale: hit.stale
574
+ };
575
+ });
576
+ }
577
+ function active(ctx) {
578
+ return withClient(ctx, async (client, websiteId) => {
579
+ return (await cached(ctx.kv, `cache:active:${websiteId}`, ACTIVE_MAX_AGE, () => fetchActive(client, websiteId))).value;
580
+ });
581
+ }
582
+ //#endregion
583
+ //#region src/routes/settings.ts
584
+ function view(s) {
585
+ const { apiKey, password, ...values } = s;
586
+ return {
587
+ values,
588
+ secretsSet: {
589
+ apiKey: apiKey !== null,
590
+ password: password !== null
591
+ }
592
+ };
593
+ }
594
+ async function getSettings(ctx) {
595
+ return view(await readSettings(ctx.settings));
596
+ }
597
+ const optionalText = z.string().trim().max(2048).nullable().optional();
598
+ /**
599
+ * URL fields are validated in the input schema, not by throwing from the handler: in `astro dev` a
600
+ * PluginRouteError thrown here is a different class instance from the host's and surfaces as a
601
+ * generic INTERNAL_ERROR, while schema failures always come back as VALIDATION_ERROR.
602
+ */
603
+ const optionalUrl = optionalText.refine((value) => !value || normalizeBaseUrl(value) !== null, { message: "Must be an address starting with https:// or http://" });
604
+ /** Secrets: a string replaces the stored value, null clears it, omitted keeps it. */
605
+ const secretInput = z.string().max(4096).nullable().optional();
606
+ const saveSettingsInput = z.object({
607
+ hosting: z.enum(["self-hosted", "cloud"]).refine((h) => h !== "cloud", { message: "Umami Cloud support arrives in 0.2.0" }).optional(),
608
+ baseUrl: optionalUrl,
609
+ region: z.enum([
610
+ "default",
611
+ "us",
612
+ "eu"
613
+ ]).optional(),
614
+ authMode: z.enum(["apiKey", "password"]).optional(),
615
+ apiKey: secretInput,
616
+ username: optionalText,
617
+ password: secretInput,
618
+ websiteId: optionalText,
619
+ injectScript: z.boolean().optional(),
620
+ clientRouter: z.boolean().optional(),
621
+ scriptUrl: optionalUrl
622
+ });
623
+ async function saveSettings(ctx) {
624
+ const input = ctx.input;
625
+ const writes = [];
626
+ const put = (key, value) => {
627
+ if (value === void 0) return;
628
+ if (value === null || value === "") writes.push(ctx.settings.delete(key));
629
+ else writes.push(ctx.settings.set(key, value));
630
+ };
631
+ put("hosting", input.hosting);
632
+ put("baseUrl", input.baseUrl === void 0 ? void 0 : normalizeBaseUrl(input.baseUrl ?? null));
633
+ put("region", input.region);
634
+ put("authMode", input.authMode);
635
+ put("apiKey", input.apiKey === void 0 ? void 0 : input.apiKey?.trim() || null);
636
+ put("username", input.username);
637
+ put("password", input.password);
638
+ put("websiteId", input.websiteId);
639
+ put("injectScript", input.injectScript);
640
+ put("clientRouter", input.clientRouter);
641
+ put("scriptUrl", input.scriptUrl);
642
+ await Promise.all(writes);
643
+ if (input.baseUrl !== void 0 || input.username !== void 0 || input.password !== void 0 || input.hosting !== void 0) await ctx.kv.delete(TOKEN_KEY);
644
+ return getSettings(ctx);
645
+ }
646
+ function clientFor(ctx, s) {
647
+ const http = ctx.http;
648
+ if (!http) throw new UmamiError("unreachable", "EmDash did not grant this plugin network access.");
649
+ return createUmamiClient(s, {
650
+ fetch: (url, init) => http.fetch(url, init),
651
+ kv: ctx.kv
652
+ });
653
+ }
654
+ /** Checks the saved settings against Umami. Returns failures as data so the UI can explain them. */
655
+ async function testConnection(ctx) {
656
+ const s = await readSettings(ctx.settings);
657
+ try {
658
+ const client = clientFor(ctx, s);
659
+ const websites = await listWebsites(client);
660
+ let website = websites.find((w) => w.id === s.websiteId) ?? null;
661
+ if (!website && s.websiteId) {
662
+ website = await getWebsite(client, s.websiteId);
663
+ if (website) websites.push(website);
664
+ }
665
+ return {
666
+ ok: true,
667
+ websites,
668
+ website
669
+ };
670
+ } catch (error) {
671
+ if (error instanceof UmamiError) {
672
+ if (error.cause) ctx.log.warn("Umami request failed", {
673
+ code: error.code,
674
+ cause: describeCause(error.cause)
675
+ });
676
+ return {
677
+ ok: false,
678
+ code: error.code,
679
+ message: error.message
680
+ };
681
+ }
682
+ ctx.log.error("Umami connection test failed", { error: error instanceof Error ? error.message : String(error) });
683
+ return {
684
+ ok: false,
685
+ code: "upstream",
686
+ message: "The connection test failed unexpectedly. See the server log."
687
+ };
688
+ }
689
+ }
690
+ //#endregion
691
+ //#region src/shared/tracker.ts
692
+ const CLOUD_SCRIPT_URL = "https://cloud.umami.is/script.js";
693
+ /** The tracker script a site should load: the custom URL if set, else the hosting default. */
694
+ function defaultScriptUrl(hosting, baseUrl, scriptUrl) {
695
+ if (scriptUrl) return scriptUrl;
696
+ if (hosting === "cloud") return CLOUD_SCRIPT_URL;
697
+ return baseUrl ? `${baseUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/script.js` : null;
698
+ }
699
+ //#endregion
700
+ //#region src/tracker.ts
701
+ /**
702
+ * Pageviews for sites using Astro's <ClientRouter />. Umami's own tracking hooks pushState only,
703
+ * so back and forward are missed, and a re-run tracker tag counts forward navigations twice.
704
+ * With `data-auto-pageview="false"` this sends one pageview per `astro:page-load` instead,
705
+ * passing the URL explicitly because Umami's stored URL is stale after a back navigation. On a
706
+ * page without <ClientRouter /> (no `astro-view-transitions-enabled` meta) it tracks once on
707
+ * DOMContentLoaded, so a wrong setting never stops tracking.
708
+ */
709
+ const CLIENT_ROUTER_SCRIPT = `(()=>{let prev;const send=()=>{const u=window.umami;if(!u)return false;const url=location.href;u.track((p)=>({...p,url,title:document.title,...(prev?{referrer:prev}:{})}));prev=location.pathname+location.search;return true};const track=()=>{let n=0;const t=()=>{send()||++n>100||setTimeout(t,50)};t()};document.addEventListener("astro:page-load",track);document.addEventListener("DOMContentLoaded",()=>{document.querySelector('meta[name="astro-view-transitions-enabled"]')||track()})})();`;
710
+ /**
711
+ * The Umami tracker as page fragments, or null when tracking is off or incomplete.
712
+ * EmDash HTML-escapes attribute values and drops on* attributes when rendering.
713
+ */
714
+ async function trackerFragment(event, settings) {
715
+ if (event.page.path.startsWith("/_emdash")) return null;
716
+ if (await settings.get("injectScript") !== true) return null;
717
+ const s = await readSettings(settings);
718
+ const src = defaultScriptUrl(s.hosting, s.baseUrl, s.scriptUrl);
719
+ if (!src || !s.websiteId || !normalizeBaseUrl(src)) return null;
720
+ const tracker = {
721
+ kind: "external-script",
722
+ placement: "head",
723
+ src,
724
+ defer: true,
725
+ attributes: { "data-website-id": s.websiteId },
726
+ key: "umami-tracker"
727
+ };
728
+ if (!s.clientRouter) return tracker;
729
+ return [{
730
+ ...tracker,
731
+ attributes: {
732
+ ...tracker.attributes,
733
+ "data-auto-pageview": "false"
734
+ }
735
+ }, {
736
+ kind: "inline-script",
737
+ placement: "head",
738
+ code: CLIENT_ROUTER_SCRIPT,
739
+ key: "umami-client-router"
740
+ }];
741
+ }
742
+ //#endregion
743
+ //#region src/index.ts
744
+ const ADMIN_ENTRY = `${PACKAGE_NAME}/admin`;
745
+ /**
746
+ * Descriptor for astro.config.mjs:
747
+ *
748
+ * import { umamiPlugin } from "@tdhftw/emdash-plugin-umami";
749
+ * emdash({ plugins: [umamiPlugin()] })
750
+ */
751
+ function umamiPlugin(options = {}) {
752
+ return {
753
+ id: PLUGIN_ID,
754
+ version: PLUGIN_VERSION,
755
+ format: "native",
756
+ entrypoint: PACKAGE_NAME,
757
+ adminEntry: ADMIN_ENTRY,
758
+ options
759
+ };
760
+ }
761
+ async function status(ctx) {
762
+ const s = await readSettings(ctx.settings);
763
+ const missing = missingSettings(s);
764
+ return {
765
+ configured: missing.length === 0,
766
+ missing,
767
+ hosting: s.hosting,
768
+ authMode: s.authMode,
769
+ websiteId: s.websiteId,
770
+ dashboardUrl: dashboardUrl(s),
771
+ injectScript: s.injectScript,
772
+ hasHttp: Boolean(ctx.http)
773
+ };
774
+ }
775
+ /** Runtime definition. EmDash imports this named export from the package root. */
776
+ function createPlugin(_options = {}) {
777
+ return definePlugin({
778
+ id: PLUGIN_ID,
779
+ version: PLUGIN_VERSION,
780
+ capabilities: ["network:request:unrestricted", "hooks.page-fragments:register"],
781
+ hooks: { "page:fragments": (event, ctx) => trackerFragment(event, ctx.settings) },
782
+ admin: {
783
+ entry: ADMIN_ENTRY,
784
+ settingsSchema,
785
+ pages: [{
786
+ path: "/",
787
+ label: "Analytics",
788
+ icon: "chart-line"
789
+ }],
790
+ widgets: [{
791
+ id: "umami-overview",
792
+ title: "Visitors",
793
+ size: "half"
794
+ }]
795
+ },
796
+ routes: {
797
+ status: {
798
+ permission: "plugins:read",
799
+ handler: status
800
+ },
801
+ overview: {
802
+ permission: "plugins:read",
803
+ input: overviewInput,
804
+ handler: overview
805
+ },
806
+ active: {
807
+ permission: "plugins:read",
808
+ handler: active
809
+ },
810
+ settings: {
811
+ permission: "plugins:manage",
812
+ handler: getSettings
813
+ },
814
+ "settings/save": {
815
+ permission: "plugins:manage",
816
+ input: saveSettingsInput,
817
+ handler: saveSettings
818
+ },
819
+ "connection/test": {
820
+ permission: "plugins:manage",
821
+ handler: testConnection
822
+ }
823
+ }
824
+ });
825
+ }
826
+ //#endregion
827
+ export { PLUGIN_ID, PLUGIN_VERSION, createPlugin, createPlugin as default, umamiPlugin };