@happyvertical/smrt-analytics 0.57.21 → 0.57.23

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/AGENTS.md CHANGED
@@ -95,3 +95,10 @@ passes in memory and renders the help. See
95
95
  - **JSON fields**: params, dimensions, metrics use string storage with getter/setter helpers
96
96
  - **Measurement IDs differ per platform**: G-XXXXXX (GA4 web) vs Firebase app ID (mobile)
97
97
  - **`analyzeResults` is now a single AI call**: previously issued a second freeform `do()` to summarize "top 3 insights"; folded into the registered template, and `insights` mirrors `analysis` in the response shape.
98
+
99
+ ## Browser tracking (#4251)
100
+
101
+ The isolated `./browser` subpath owns the optional browser tracker and Matomo
102
+ HTTP adapter; it must never import model registration or the server SDK.
103
+ The optional `smrt-ui/analytics` context connects shared component standard
104
+ events to the configured provider. See [browser contract](agents/browser.md).
package/README.md CHANGED
@@ -151,3 +151,7 @@ Re-exported from `./types` for SDK compatibility: `AnalyticsInterface`, `Analyti
151
151
 
152
152
  See [`AGENTS.md`](./AGENTS.md) for package architecture, invariants, validation,
153
153
  and contributor guidance.
154
+
155
+ ## Browser tracking
156
+
157
+ Use the isolated `@happyvertical/smrt-analytics/browser` entry for opt-in browser/SPA tracking and the Matomo adapter. Shared components emit static standard events through the optional `@happyvertical/smrt-ui/analytics` context. See [browser integration and privacy contract](agents/browser.md).
@@ -0,0 +1,64 @@
1
+ # Browser analytics (#4251)
2
+
3
+ `@happyvertical/smrt-analytics/browser` is an isolated browser entry, without
4
+ model registration or server SDK imports. SDK analytics already owns server
5
+ Matomo reporting/tracking/admin; its Node transport is not browser-compatible.
6
+ This adapter uses Matomo's free Tracking HTTP API:
7
+ https://developer.matomo.org/api-reference/tracking-api
8
+
9
+ Hosts install `setAnalyticsContext(name => tracker?.track(name))` from
10
+ `@happyvertical/smrt-ui/analytics` during root component initialization, then
11
+ create/dispose a tracker on browser mount/config changes. Standard events are
12
+ allowlisted by default; apps add only business-specific static definitions.
13
+ `STANDARD_ANALYTICS_EVENTS` is also exported for inspection.
14
+ Components have no Matomo dependency and absent context is a noop. Configure
15
+ `PUBLIC_ANALYTICS_ENABLED=true`, `PUBLIC_MATOMO_URL`, `PUBLIC_MATOMO_SITE_ID`.
16
+ Development additionally requires `PUBLIC_ANALYTICS_ALLOW_DEVELOPMENT=true`.
17
+ Optional `PUBLIC_ANALYTICS_HEARTBEAT_SECONDS` (minimum 5) sends a Matomo ping
18
+ only while the document is visible/focused. No background/unload listeners.
19
+
20
+ On SvelteKit afterNavigate, use `safeRoutePath(page.route.id)` (null excludes
21
+ unknown routes), combine with origin and a static title, then `pageview`.
22
+ Do not pass location.pathname for dynamic routes. Queries/fragments are always
23
+ removed. Virtual pages and iframe event-only `context({url,title})` are supported.
24
+ No browser location URL, referrer or document title is read implicitly.
25
+
26
+ Shared events: Planner opened and successful explicit cookbook application;
27
+ project question open→answered and decision open→decided; preview link open
28
+ (engagement), pending→approved/rejected (persisted props). Initial historical
29
+ rows and unchanged/error/cancelled results never count as conversions. Planner
30
+ hydration does not count as applying a cookbook. Apps must update authoritative
31
+ props after their saves and must not emit these events again. No shared
32
+ invitation acceptance UI exists, so this slice cannot claim that server outcome.
33
+ No server-side conversion retry/attribution or exact-once persistence is promised.
34
+
35
+ Views dedupe consecutive sanitized URLs; events dedupe per static key and page
36
+ context. Counts therefore measure a successful outcome per view, not total
37
+ transactions. Business outcomes can pass `track(key, {dedupeKey: receiptId})`
38
+ for distinct confirmed submissions; this key stays in memory and never enters
39
+ the provider payload. Failed transport is best effort without retries/logging, and
40
+ no-cors responses cannot prove Matomo acceptance. Actual collector receipt is a
41
+ separate integration check. Network requests carry no cookie, HTTP referrer,
42
+ visitor ID, private data, dimensions, query, token, chat, email or freeform input.
43
+ The server naturally receives IP/User-Agent headers; configure Matomo retention
44
+ and IP anonymization at the deployment boundary. No unique visitor accuracy is
45
+ promised without an identifier. CSP connect-src must allow the configured host.
46
+
47
+ ## Behavior-to-test matrix
48
+
49
+ All rows: actor browser visitor; executor N/A (no persistence/transactions);
50
+ SQL dialect N/A (no database); runtime browser and inert SSR.
51
+
52
+ | Invariant | Trigger / positive | Negative | Edge / level / command |
53
+ |---|---|---|---|
54
+ | Config gating | explicit production enable | missing, malformed, dev, SSR | env public strings; unit; analytics browser.test.ts |
55
+ | Privacy/navigation | template page + static allowlist | query/hash, unknown key/cross-origin | provider seam; unit; analytics browser.test.ts |
56
+ | Lifecycle/dedupe | new SPA route + success | repeated route/key, disposed | timer/provider errors; unit; analytics browser.test.ts |
57
+ | Engagement | visible focused interval | hidden/unfocused/disposed | timer; unit; analytics browser.test.ts |
58
+ | Matomo protocol | view/event/ping | invalid endpoint/site | fetch minimal payload/failure; unit; analytics browser.test.ts |
59
+ | Auto shared outcomes | observed persisted transition | historical, unchanged/failure, rerender | context + component; ui analytics and projects UI tests |
60
+ | Planner success | explicit applied signal | hydration, failed commands, unmounted | state + lifecycle; planner tests |
61
+
62
+ Focused validation uses package `exec vitest run` with the named tests; build
63
+ smrt-ui first, then analytics/projects/planner; verify package exports afterward.
64
+ This is a new feature; base bug reproduction N/A.
@@ -0,0 +1,85 @@
1
+ /** Browser-only analytics. No model registration, identity, storage, or implicit URLs. */
2
+ export interface AnalyticsEventDefinition {
3
+ category: string;
4
+ action: string;
5
+ }
6
+ export interface AnalyticsPage {
7
+ /** A static route template or virtual page, never a user-provided path. */
8
+ url: string;
9
+ /** Static application title, never a record name or document.title. */
10
+ title?: string;
11
+ }
12
+ export interface BrowserAnalyticsProvider {
13
+ pageview(page: AnalyticsPage): void | Promise<unknown>;
14
+ event(event: AnalyticsEventDefinition, page: AnalyticsPage): void | Promise<unknown>;
15
+ heartbeat?(page: AnalyticsPage): void | Promise<unknown>;
16
+ }
17
+ export interface BrowserAnalyticsConfig {
18
+ enabled: boolean;
19
+ development?: boolean;
20
+ allowDevelopment?: boolean;
21
+ heartbeatSeconds?: number;
22
+ }
23
+ export declare const STANDARD_ANALYTICS_EVENTS: {
24
+ readonly planner_opened: {
25
+ readonly category: "planner";
26
+ readonly action: "opened";
27
+ };
28
+ readonly planner_cookbook_applied: {
29
+ readonly category: "planner";
30
+ readonly action: "cookbook_applied";
31
+ };
32
+ readonly project_question_answered: {
33
+ readonly category: "project";
34
+ readonly action: "question_answered";
35
+ };
36
+ readonly project_decision_resolved: {
37
+ readonly category: "project";
38
+ readonly action: "decision_resolved";
39
+ };
40
+ readonly project_preview_opened: {
41
+ readonly category: "project";
42
+ readonly action: "preview_opened";
43
+ };
44
+ readonly project_preview_approved: {
45
+ readonly category: "project";
46
+ readonly action: "preview_approved";
47
+ };
48
+ readonly project_preview_rejected: {
49
+ readonly category: "project";
50
+ readonly action: "preview_rejected";
51
+ };
52
+ };
53
+ /** Public environment strings only. Missing or malformed settings stay disabled. */
54
+ export declare function resolveBrowserAnalyticsConfig(env: Record<string, string | undefined>, { development }: {
55
+ development: boolean;
56
+ }): {
57
+ enabled: boolean;
58
+ development: boolean;
59
+ allowDevelopment: boolean;
60
+ baseUrl: string;
61
+ siteId: string;
62
+ heartbeatSeconds: number | undefined;
63
+ };
64
+ /** SvelteKit's route ID, never location.pathname; missing routes are excluded. */
65
+ export declare function safeRoutePath(routeId: string | null): string | null;
66
+ export declare function createBrowserAnalytics(options: BrowserAnalyticsConfig & {
67
+ provider: BrowserAnalyticsProvider;
68
+ events?: Record<string, AnalyticsEventDefinition>;
69
+ }): {
70
+ context: (page: AnalyticsPage) => boolean;
71
+ pageview(page: AnalyticsPage): boolean;
72
+ /** Static allowlist only. At most once per key and current page context. */
73
+ track(key: string, eventOptions?: {
74
+ dedupeKey?: string;
75
+ }): boolean;
76
+ dispose(): void;
77
+ };
78
+ /** Matomo's free Tracking HTTP API; deliberately avoids its global JS tracker. */
79
+ export declare function createMatomoProvider(options: {
80
+ baseUrl: string;
81
+ siteId: string;
82
+ /** Transport injection for tests; production uses browser fetch. */
83
+ fetch?: typeof fetch;
84
+ }): BrowserAnalyticsProvider;
85
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,KAAK,CACH,KAAK,EAAE,wBAAwB,EAC/B,IAAI,EAAE,aAAa,GAClB,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3B,SAAS,CAAC,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1D;AACD,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AACD,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAc5B,CAAC;AAEX,oFAAoF;AACpF,wBAAgB,6BAA6B,CAC3C,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EACvC,EAAE,WAAW,EAAE,EAAE;IAAE,WAAW,EAAE,OAAO,CAAA;CAAE;;;;;;;EAoB1C;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAGnE;AA4BD,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,sBAAsB,GAAG;IAChC,QAAQ,EAAE,wBAAwB,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;CACnD;oBAmBsB,aAAa,KAAG,OAAO;mBA2C7B,aAAa,GAAG,OAAO;IAOtC,4EAA4E;eACjE,MAAM,iBAAiB;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO;;EAiBrE;AAED,kFAAkF;AAClF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB,GAAG,wBAAwB,CAoC3B"}
@@ -0,0 +1,175 @@
1
+ //#region src/browser.ts
2
+ var STANDARD_ANALYTICS_EVENTS = {
3
+ planner_opened: {
4
+ category: "planner",
5
+ action: "opened"
6
+ },
7
+ planner_cookbook_applied: {
8
+ category: "planner",
9
+ action: "cookbook_applied"
10
+ },
11
+ project_question_answered: {
12
+ category: "project",
13
+ action: "question_answered"
14
+ },
15
+ project_decision_resolved: {
16
+ category: "project",
17
+ action: "decision_resolved"
18
+ },
19
+ project_preview_opened: {
20
+ category: "project",
21
+ action: "preview_opened"
22
+ },
23
+ project_preview_approved: {
24
+ category: "project",
25
+ action: "preview_approved"
26
+ },
27
+ project_preview_rejected: {
28
+ category: "project",
29
+ action: "preview_rejected"
30
+ }
31
+ };
32
+ function resolveBrowserAnalyticsConfig(env, { development }) {
33
+ const seconds = Number(env.PUBLIC_ANALYTICS_HEARTBEAT_SECONDS);
34
+ const baseUrl = env.PUBLIC_MATOMO_URL ?? "";
35
+ const siteId = env.PUBLIC_MATOMO_SITE_ID ?? "";
36
+ const allowDevelopment = env.PUBLIC_ANALYTICS_ALLOW_DEVELOPMENT === "true";
37
+ return {
38
+ enabled: env.PUBLIC_ANALYTICS_ENABLED === "true" && (!development || allowDevelopment) && validEndpoint(baseUrl) !== null && /^\d+$/.test(siteId) && Number(siteId) > 0,
39
+ development,
40
+ allowDevelopment,
41
+ baseUrl,
42
+ siteId,
43
+ heartbeatSeconds: Number.isFinite(seconds) && seconds >= 5 ? seconds : void 0
44
+ };
45
+ }
46
+ function safeRoutePath(routeId) {
47
+ if (!routeId || !routeId.startsWith("/")) return null;
48
+ return routeId.replace(/\/\([^/]*\)/g, "") || "/";
49
+ }
50
+ function validEndpoint(value) {
51
+ try {
52
+ const url = new URL(value);
53
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
54
+ return url;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ function quietly(run) {
60
+ try {
61
+ Promise.resolve(run()).catch(() => {});
62
+ } catch {}
63
+ }
64
+ function createBrowserAnalytics(options) {
65
+ const active = options.enabled && (!options.development || options.allowDevelopment) && typeof window !== "undefined";
66
+ const definitions = new Map(Object.entries({
67
+ ...STANDARD_ANALYTICS_EVENTS,
68
+ ...options.events
69
+ }).map(([key, value]) => [key, {
70
+ category: value.category,
71
+ action: value.action
72
+ }]));
73
+ let disposed = false;
74
+ let current;
75
+ let lastViewed;
76
+ const emitted = /* @__PURE__ */ new Set();
77
+ const permitted = () => active && !disposed;
78
+ function context(page) {
79
+ if (!permitted()) return false;
80
+ try {
81
+ const url = new URL(page.url, window.location.origin);
82
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.origin !== window.location.origin) {
83
+ current = void 0;
84
+ return false;
85
+ }
86
+ url.search = "";
87
+ url.hash = "";
88
+ if (current?.url !== url.href) emitted.clear();
89
+ current = {
90
+ url: url.href,
91
+ ...page.title ? { title: page.title } : {}
92
+ };
93
+ return true;
94
+ } catch {
95
+ current = void 0;
96
+ return false;
97
+ }
98
+ }
99
+ const seconds = options.heartbeatSeconds;
100
+ const timer = active && Number.isFinite(seconds) && seconds >= 5 && options.provider.heartbeat ? setInterval(() => {
101
+ if (permitted() && current && document.visibilityState === "visible" && document.hasFocus()) {
102
+ const page = { ...current };
103
+ quietly(() => options.provider.heartbeat?.(page));
104
+ }
105
+ }, seconds * 1e3) : void 0;
106
+ return {
107
+ context,
108
+ pageview(page) {
109
+ if (!context(page) || current.url === lastViewed) return false;
110
+ lastViewed = current.url;
111
+ const safe = { ...current };
112
+ quietly(() => options.provider.pageview(safe));
113
+ return true;
114
+ },
115
+ /** Static allowlist only. At most once per key and current page context. */
116
+ track(key, eventOptions) {
117
+ const definition = definitions.get(key);
118
+ const identity = JSON.stringify([key, eventOptions?.dedupeKey ?? null]);
119
+ if (!permitted() || !current || !definition || emitted.has(identity)) return false;
120
+ emitted.add(identity);
121
+ const page = { ...current };
122
+ quietly(() => options.provider.event({ ...definition }, page));
123
+ return true;
124
+ },
125
+ dispose() {
126
+ disposed = true;
127
+ if (timer !== void 0) clearInterval(timer);
128
+ emitted.clear();
129
+ current = void 0;
130
+ }
131
+ };
132
+ }
133
+ function createMatomoProvider(options) {
134
+ const base = validEndpoint(options.baseUrl);
135
+ if (!base || !/^\d+$/.test(options.siteId) || Number(options.siteId) <= 0) throw new Error("Invalid public analytics configuration");
136
+ const endpoint = new URL(`${base.href.replace(/\/$/, "")}/matomo.php`);
137
+ function send(page, extra = {}) {
138
+ const url = new URL(page.url);
139
+ url.search = "";
140
+ url.hash = "";
141
+ url.username = "";
142
+ url.password = "";
143
+ const body = new URLSearchParams({
144
+ idsite: options.siteId,
145
+ rec: "1",
146
+ apiv: "1",
147
+ cookie: "0",
148
+ send_image: "0",
149
+ url: url.href,
150
+ ...page.title ? { action_name: page.title } : {},
151
+ ...extra
152
+ });
153
+ return (options.fetch ?? globalThis.fetch)(endpoint.href, {
154
+ method: "POST",
155
+ body,
156
+ mode: "no-cors",
157
+ credentials: "omit",
158
+ referrerPolicy: "no-referrer",
159
+ keepalive: true
160
+ });
161
+ }
162
+ return {
163
+ pageview: (page) => send(page),
164
+ event: (event, page) => send(page, {
165
+ e_c: event.category,
166
+ e_a: event.action,
167
+ ca: "1"
168
+ }),
169
+ heartbeat: (page) => send(page, { ping: "1" })
170
+ };
171
+ }
172
+ //#endregion
173
+ export { STANDARD_ANALYTICS_EVENTS, createBrowserAnalytics, createMatomoProvider, resolveBrowserAnalyticsConfig, safeRoutePath };
174
+
175
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.js","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/** Browser-only analytics. No model registration, identity, storage, or implicit URLs. */\nexport interface AnalyticsEventDefinition {\n category: string;\n action: string;\n}\nexport interface AnalyticsPage {\n /** A static route template or virtual page, never a user-provided path. */\n url: string;\n /** Static application title, never a record name or document.title. */\n title?: string;\n}\nexport interface BrowserAnalyticsProvider {\n pageview(page: AnalyticsPage): void | Promise<unknown>;\n event(\n event: AnalyticsEventDefinition,\n page: AnalyticsPage,\n ): void | Promise<unknown>;\n heartbeat?(page: AnalyticsPage): void | Promise<unknown>;\n}\nexport interface BrowserAnalyticsConfig {\n enabled: boolean;\n development?: boolean;\n allowDevelopment?: boolean;\n heartbeatSeconds?: number;\n}\nexport const STANDARD_ANALYTICS_EVENTS = {\n planner_opened: { category: 'planner', action: 'opened' },\n planner_cookbook_applied: { category: 'planner', action: 'cookbook_applied' },\n project_question_answered: {\n category: 'project',\n action: 'question_answered',\n },\n project_decision_resolved: {\n category: 'project',\n action: 'decision_resolved',\n },\n project_preview_opened: { category: 'project', action: 'preview_opened' },\n project_preview_approved: { category: 'project', action: 'preview_approved' },\n project_preview_rejected: { category: 'project', action: 'preview_rejected' },\n} as const;\n\n/** Public environment strings only. Missing or malformed settings stay disabled. */\nexport function resolveBrowserAnalyticsConfig(\n env: Record<string, string | undefined>,\n { development }: { development: boolean },\n) {\n const seconds = Number(env.PUBLIC_ANALYTICS_HEARTBEAT_SECONDS);\n const baseUrl = env.PUBLIC_MATOMO_URL ?? '';\n const siteId = env.PUBLIC_MATOMO_SITE_ID ?? '';\n const allowDevelopment = env.PUBLIC_ANALYTICS_ALLOW_DEVELOPMENT === 'true';\n return {\n enabled:\n env.PUBLIC_ANALYTICS_ENABLED === 'true' &&\n (!development || allowDevelopment) &&\n validEndpoint(baseUrl) !== null &&\n /^\\d+$/.test(siteId) &&\n Number(siteId) > 0,\n development,\n allowDevelopment,\n baseUrl,\n siteId,\n heartbeatSeconds:\n Number.isFinite(seconds) && seconds >= 5 ? seconds : undefined,\n };\n}\n\n/** SvelteKit's route ID, never location.pathname; missing routes are excluded. */\nexport function safeRoutePath(routeId: string | null): string | null {\n if (!routeId || !routeId.startsWith('/')) return null;\n return routeId.replace(/\\/\\([^/]*\\)/g, '') || '/';\n}\n\nfunction validEndpoint(value: string): URL | null {\n try {\n const url = new URL(value);\n if (\n !['https:', 'http:'].includes(url.protocol) ||\n url.username ||\n url.password ||\n url.search ||\n url.hash\n )\n return null;\n return url;\n } catch {\n return null;\n }\n}\n\n/** A provider failure must never interrupt an application operation. */\nfunction quietly(run: () => unknown) {\n try {\n Promise.resolve(run()).catch(() => {});\n } catch {\n /* no payload logging */\n }\n}\n\nexport function createBrowserAnalytics(\n options: BrowserAnalyticsConfig & {\n provider: BrowserAnalyticsProvider;\n events?: Record<string, AnalyticsEventDefinition>;\n },\n) {\n const active =\n options.enabled &&\n (!options.development || options.allowDevelopment) &&\n typeof window !== 'undefined';\n const definitions = new Map(\n Object.entries({ ...STANDARD_ANALYTICS_EVENTS, ...options.events }).map(\n ([key, value]) => [\n key,\n { category: value.category, action: value.action },\n ],\n ),\n );\n let disposed = false;\n let current: AnalyticsPage | undefined;\n let lastViewed: string | undefined;\n const emitted = new Set<string>();\n const permitted = () => active && !disposed;\n function context(page: AnalyticsPage): boolean {\n if (!permitted()) return false;\n try {\n const url = new URL(page.url, window.location.origin);\n if (\n !['https:', 'http:'].includes(url.protocol) ||\n url.username ||\n url.password ||\n url.origin !== window.location.origin\n ) {\n current = undefined;\n return false;\n }\n url.search = '';\n url.hash = '';\n if (current?.url !== url.href) emitted.clear();\n current = { url: url.href, ...(page.title ? { title: page.title } : {}) };\n return true;\n } catch {\n current = undefined;\n return false;\n }\n }\n const seconds = options.heartbeatSeconds;\n const timer =\n active &&\n Number.isFinite(seconds) &&\n seconds! >= 5 &&\n options.provider.heartbeat\n ? setInterval(() => {\n if (\n permitted() &&\n current &&\n document.visibilityState === 'visible' &&\n document.hasFocus()\n ) {\n const page = { ...current };\n quietly(() => options.provider.heartbeat?.(page));\n }\n }, seconds! * 1000)\n : undefined;\n return {\n context,\n pageview(page: AnalyticsPage): boolean {\n if (!context(page) || current!.url === lastViewed) return false;\n lastViewed = current!.url;\n const safe = { ...current! };\n quietly(() => options.provider.pageview(safe));\n return true;\n },\n /** Static allowlist only. At most once per key and current page context. */\n track(key: string, eventOptions?: { dedupeKey?: string }): boolean {\n const definition = definitions.get(key);\n const identity = JSON.stringify([key, eventOptions?.dedupeKey ?? null]);\n if (!permitted() || !current || !definition || emitted.has(identity))\n return false;\n emitted.add(identity);\n const page = { ...current };\n quietly(() => options.provider.event({ ...definition }, page));\n return true;\n },\n dispose() {\n disposed = true;\n if (timer !== undefined) clearInterval(timer);\n emitted.clear();\n current = undefined;\n },\n };\n}\n\n/** Matomo's free Tracking HTTP API; deliberately avoids its global JS tracker. */\nexport function createMatomoProvider(options: {\n baseUrl: string;\n siteId: string;\n /** Transport injection for tests; production uses browser fetch. */\n fetch?: typeof fetch;\n}): BrowserAnalyticsProvider {\n const base = validEndpoint(options.baseUrl);\n if (!base || !/^\\d+$/.test(options.siteId) || Number(options.siteId) <= 0)\n throw new Error('Invalid public analytics configuration');\n const endpoint = new URL(`${base.href.replace(/\\/$/, '')}/matomo.php`);\n function send(page: AnalyticsPage, extra: Record<string, string> = {}) {\n const url = new URL(page.url);\n url.search = '';\n url.hash = '';\n url.username = '';\n url.password = '';\n const body = new URLSearchParams({\n idsite: options.siteId,\n rec: '1',\n apiv: '1',\n cookie: '0',\n send_image: '0',\n url: url.href,\n ...(page.title ? { action_name: page.title } : {}),\n ...extra,\n });\n return (options.fetch ?? globalThis.fetch)(endpoint.href, {\n method: 'POST',\n body,\n mode: 'no-cors',\n credentials: 'omit',\n referrerPolicy: 'no-referrer',\n keepalive: true,\n });\n }\n return {\n pageview: (page) => send(page),\n event: (event, page) =>\n send(page, { e_c: event.category, e_a: event.action, ca: '1' }),\n heartbeat: (page) => send(page, { ping: '1' }),\n };\n}\n"],"mappings":"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"}