Help improve Share HTML
-
Allow optional usage analytics{config.ga4MeasurementId ? ", including Google Analytics," : ""} to understand how people find and use the site.
- File contents, titles, and access keys are never included. Basic service activity is counted separately.
+
Usage analytics: {consent === "granted" ? "on" : "off"}
+
Usage analytics{config.ga4MeasurementId ? ", including Google Analytics," : ""} is on by default to understand how people find and use the site. You can turn it off at any time.
+ File contents, titles, share links, and access keys are never included. Basic service activity is counted separately.
{privacySignal() &&
Your browser privacy preference keeps optional analytics off.
}
- choose("granted")} disabled={privacySignal()}>Allow analytics
- choose("denied")}>No thanks
+ choose("granted")} disabled={privacySignal()}>Turn on analytics
+ choose("denied")}>Turn off analytics
- > :
setOpen(true)}>Analytics preferences }
+ > :
setOpen(true)}>Analytics: {consent === "granted" ? "on" : "off"} · Preferences }
;
}
diff --git a/src/client/analytics.ts b/src/client/analytics.ts
index 1802d9d..0623ff8 100644
--- a/src/client/analytics.ts
+++ b/src/client/analytics.ts
@@ -1,7 +1,7 @@
type Acquisition = { source?: string; medium?: string; campaign?: string; referrer_domain?: string };
type Config = { analyticsEnabled?: boolean; ga4MeasurementId?: string };
export type BrowserEvent = "page_view" | "upload_started" | "upload_failed" | "share_link_copied";
-export type Consent = "granted" | "denied" | null;
+export type Consent = "granted" | "denied";
type Context = { session_id: string; acquisition: Acquisition };
const CONSENT_KEY = "sharehtml.analytics.consent";
@@ -12,6 +12,7 @@ let config: Config = {};
let context: Context | null = null;
let configuredGa: string | null = null;
let lastPage: string | null = null;
+let inMemoryConsent: Consent | null = null;
type AnalyticsWindow = Window & {
dataLayer?: unknown[];
@@ -25,14 +26,21 @@ export function privacySignal(): boolean {
export function readAnalyticsConsent(): Consent {
if (typeof window === "undefined" || privacySignal()) return "denied";
+ if (inMemoryConsent) return inMemoryConsent;
try {
const value = window.localStorage.getItem(CONSENT_KEY);
- return value === "granted" || value === "denied" ? value : null;
+ return value === "granted" || value === "denied" ? value : "granted";
} catch { return "denied"; }
}
-export function setAnalyticsConsent(consent: Exclude
): void {
- try { window.localStorage.setItem(CONSENT_KEY, consent); } catch { return; }
+export function setAnalyticsConsent(consent: Consent): void {
+ try {
+ window.localStorage.setItem(CONSENT_KEY, consent);
+ inMemoryConsent = null;
+ } catch {
+ // A failed preference write must not prevent opting out in this page.
+ inMemoryConsent = consent;
+ }
if (consent === "denied") {
context = null;
lastPage = null;
diff --git a/tests/client/analytics-notice.test.tsx b/tests/client/analytics-notice.test.tsx
new file mode 100644
index 0000000..3fd63a6
--- /dev/null
+++ b/tests/client/analytics-notice.test.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+vi.mock("@tanstack/react-router", () => ({ useRouterState: () => "/" }));
+vi.mock("../../src/client/queries", () => ({
+ useConfig: () => ({ data: { analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" } }),
+}));
+vi.mock("../../src/client/webmcp", () => ({ reportWebMcpState: vi.fn() }));
+
+beforeEach(() => {
+ vi.resetModules();
+ localStorage.clear();
+ sessionStorage.clear();
+ Object.defineProperty(navigator, "doNotTrack", { configurable: true, value: "0" });
+ Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, value: false });
+ delete (window as Window & { dataLayer?: unknown }).dataLayer;
+ delete (window as Window & { gtag?: unknown }).gtag;
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ document.querySelectorAll('script[src*="googletagmanager"]').forEach(script => script.remove());
+});
+
+test("a visitor is measured by default and can turn analytics off and back on through the notice", async () => {
+ const { AnalyticsNotice } = await import("../../src/client/AnalyticsNotice");
+ const user = userEvent.setup();
+ render( );
+ expect(fetch).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Analytics: on · Preferences" }));
+ expect(screen.getByText(/including Google Analytics/).textContent).toContain("on by default");
+ await user.click(screen.getByRole("button", { name: "Turn off analytics" }));
+ expect(localStorage.getItem("sharehtml.analytics.consent")).toBe("denied");
+ expect(sessionStorage.length).toBe(0);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect((window as unknown as Record)["ga-disable-G-TEST12345"]).toBe(true);
+ await user.click(screen.getByRole("button", { name: "Analytics: off · Preferences" }));
+ await user.click(screen.getByRole("button", { name: "Turn on analytics" }));
+ expect(localStorage.getItem("sharehtml.analytics.consent")).toBe("granted");
+ expect(fetch).toHaveBeenCalledTimes(2);
+});
+
+test("an existing opt-out is visible and opening preferences does not start collection", async () => {
+ localStorage.setItem("sharehtml.analytics.consent", "denied");
+ const { AnalyticsNotice } = await import("../../src/client/AnalyticsNotice");
+ render( );
+ await userEvent.setup().click(screen.getByRole("button", { name: "Analytics: off · Preferences" }));
+ expect(fetch).not.toHaveBeenCalled();
+ expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull();
+});
+
+test("a browser privacy signal keeps collection off and disables the enable button", async () => {
+ Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, value: true });
+ const { AnalyticsNotice } = await import("../../src/client/AnalyticsNotice");
+ render( );
+ await userEvent.setup().click(screen.getByRole("button", { name: "Analytics: off · Preferences" }));
+ expect((screen.getByRole("button", { name: "Turn on analytics" }) as HTMLButtonElement).disabled).toBe(true);
+ expect(fetch).not.toHaveBeenCalled();
+});
diff --git a/tests/client/analytics.test.ts b/tests/client/analytics.test.ts
index 074423c..2c53027 100644
--- a/tests/client/analytics.test.ts
+++ b/tests/client/analytics.test.ts
@@ -6,21 +6,73 @@ beforeEach(() => {
sessionStorage.clear();
delete (window as Window & { dataLayer?: unknown }).dataLayer;
delete (window as Window & { gtag?: unknown }).gtag;
+ delete (window as unknown as Record)["ga-disable-G-TEST12345"];
+ Object.defineProperty(navigator, "doNotTrack", { configurable: true, value: "0" });
+ Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, value: false });
window.history.replaceState({}, "", "/");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
});
-afterEach(() => { vi.unstubAllGlobals(); document.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()); });
+afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.querySelectorAll('script[src*="googletagmanager"]').forEach((s) => s.remove()); });
-test("optional telemetry stays off until consent; denial prevents both session storage and network calls", async () => {
+test("a new visitor gets sanitized browser and Google analytics without a preference click", async () => {
const a = await import("../../src/client/analytics");
- a.configureAnalytics({ analyticsEnabled: true });
+ a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" });
+ a.trackPage("/");
+ expect(a.readAnalyticsConsent()).toBe("granted");
+ expect(localStorage.getItem("sharehtml.analytics.consent")).toBeNull();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(document.querySelector('script[src*="googletagmanager"]')).not.toBeNull();
+ const layer = (window as unknown as { dataLayer: unknown[] }).dataLayer;
+ expect(layer.map(entry => Array.from(entry as ArrayLike)))
+ .toContainEqual(["event", "page_view", expect.objectContaining({ page_location: window.location.origin + "/" })]);
+});
+
+test("an existing opt-out prevents session storage, browser events and Google loading", async () => {
+ localStorage.setItem("sharehtml.analytics.consent", "denied");
+ const a = await import("../../src/client/analytics");
+ a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" });
a.trackBrowserEvent("page_view");
expect(fetch).not.toHaveBeenCalled();
expect(a.uploadAnalyticsContext()).toBeNull();
- a.setAnalyticsConsent("denied");
a.trackBrowserEvent("upload_started");
expect(fetch).not.toHaveBeenCalled();
expect(sessionStorage.length).toBe(0);
+ expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull();
+});
+
+test.each(["doNotTrack", "globalPrivacyControl"])("%s overrides the default and explicit enablement", async (signal) => {
+ Object.defineProperty(navigator, signal, { configurable: true, value: signal === "doNotTrack" ? "1" : true });
+ const a = await import("../../src/client/analytics");
+ a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" });
+ a.trackPage("/");
+ a.setAnalyticsConsent("granted");
+ a.trackBrowserEvent("upload_started");
+ expect(a.readAnalyticsConsent()).toBe("denied");
+ expect(fetch).not.toHaveBeenCalled();
+ expect(sessionStorage.length).toBe(0);
+ expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull();
+});
+
+test("opting out still disables an initialized Google tag when saving the preference fails", async () => {
+ const a = await import("../../src/client/analytics");
+ a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" });
+ a.trackPage("/");
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
+ a.setAnalyticsConsent("denied");
+ a.trackBrowserEvent("upload_started");
+ expect(a.readAnalyticsConsent()).toBe("denied");
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(sessionStorage.length).toBe(0);
+ expect((window as unknown as Record)["ga-disable-G-TEST12345"]).toBe(true);
+});
+
+test("unreadable saved preferences do not enable analytics", async () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
+ const a = await import("../../src/client/analytics");
+ a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" });
+ a.trackPage("/");
+ expect(fetch).not.toHaveBeenCalled();
+ expect(document.querySelector('script[src*="googletagmanager"]')).toBeNull();
});
test("attribution allowlist keeps only campaign tokens and external hostname, never secrets or referrer paths", async () => {
@@ -52,7 +104,6 @@ test("share route telemetry uses a template and never loads Google or forwards t
const a = await import("../../src/client/analytics");
window.history.replaceState({}, "", "/s/a-private-slug?accessKey=secret#key=other-secret");
a.configureAnalytics({ analyticsEnabled: true, ga4MeasurementId: "G-TEST12345" });
- a.setAnalyticsConsent("granted");
a.trackPage(window.location.pathname);
const payload = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
expect(payload.route).toBe("/s/:slug");
diff --git a/tests/client/webmcp.test.ts b/tests/client/webmcp.test.ts
index 8d566a4..8c7f107 100644
--- a/tests/client/webmcp.test.ts
+++ b/tests/client/webmcp.test.ts
@@ -101,9 +101,8 @@ test("consented WebMCP creation joins only sanitized tab context to the server u
expect(JSON.stringify(context)).not.toMatch(/secret|private|document|title|fragment|conversation/);
expect(result).toEqual({content:[{type:"text",text:'{"ok":true}'}],isError:false});
});
-test.each(["absent","denied","dnt","gpc"])("%s WebMCP creation omits analytics form context",async(signal)=>{
+test.each(["denied","dnt","gpc"])("%s WebMCP creation omits analytics form context",async(signal)=>{
const analytics=await enable();
- if(signal==="absent") localStorage.clear();
if(signal==="denied") analytics.setAnalyticsConsent("denied");
if(signal==="dnt") Object.defineProperty(navigator,"doNotTrack",{value:"1"});
if(signal==="gpc") Object.defineProperty(navigator,"globalPrivacyControl",{value:true});
@@ -113,3 +112,15 @@ test.each(["absent","denied","dnt","gpc"])("%s WebMCP creation omits analytics f
const upload=vi.mocked(fetch).mock.calls.find(([url])=>url==="/api/shares");
expect((upload?.[1]?.body as FormData).has("analytics")).toBe(false);
});
+
+test("default-on WebMCP creation attaches sanitized context without a saved preference", async () => {
+ const analytics = await import("../../src/client/analytics");
+ analytics.configureAnalytics({ analyticsEnabled: true });
+ vi.mocked(fetch).mockResolvedValue(new Response('{}', { status: 201 }));
+ const { webMcpTools } = await import("../../src/client/webmcp");
+ await webMcpTools()[3].execute({ html: "hello" });
+ const upload = vi.mocked(fetch).mock.calls.find(([url]) => url === "/api/shares");
+ const context = JSON.parse(String((upload?.[1]?.body as FormData).get("analytics")));
+ expect(context).toEqual({ session_id: expect.any(String), acquisition: {} });
+ expect(localStorage.getItem("sharehtml.analytics.consent")).toBeNull();
+});