@emplugins/emprivacy 2.0.0 → 3.0.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/dist/index.mjs CHANGED
@@ -1,6 +1,407 @@
1
1
  import { definePlugin } from "emdash";
2
2
  import { z } from "zod";
3
-
3
+ const LOCALE_KEY = /^[a-z]{2,3}(?:-[A-Za-z]{2})?$/;
4
+ const CHROME = {
5
+ en: {
6
+ acceptAll: "Accept all",
7
+ rejectNonEssential: "Reject non-essential",
8
+ customize: "Customize",
9
+ saveChoices: "Save choices",
10
+ close: "Close",
11
+ essential: "Essential",
12
+ functional: "Functional",
13
+ analytics: "Analytics",
14
+ marketing: "Marketing",
15
+ essentialNote: "Always on — needed to run the site and remember this choice.",
16
+ privacyPolicy: "Privacy policy",
17
+ cookiePolicy: "Cookie policy",
18
+ cookieSettings: "Cookie and privacy settings",
19
+ loadEmbed: "Allow embeds and load",
20
+ embedBlocked: "This embed is blocked until you allow the matching cookie category.",
21
+ embedNeedConsent: "To load this embed, allow the matching category in cookie settings.",
22
+ vendorsHeading: "What this site uses",
23
+ whatWeUse: "What we use"
24
+ },
25
+ de: {
26
+ acceptAll: "Alle akzeptieren",
27
+ rejectNonEssential: "Nicht notwendige ablehnen",
28
+ customize: "Anpassen",
29
+ saveChoices: "Auswahl speichern",
30
+ close: "Schließen",
31
+ essential: "Essenziell",
32
+ functional: "Funktional",
33
+ analytics: "Analyse",
34
+ marketing: "Marketing",
35
+ essentialNote: "Immer aktiv — erforderlich für die Website und diese Auswahl.",
36
+ privacyPolicy: "Datenschutzerklärung",
37
+ cookiePolicy: "Cookie-Richtlinie",
38
+ cookieSettings: "Cookie- und Datenschutzeinstellungen",
39
+ loadEmbed: "Einbettungen erlauben und laden",
40
+ embedBlocked: "Diese Einbettung ist gesperrt, bis Sie die passende Kategorie erlauben.",
41
+ embedNeedConsent: "Um diese Einbettung zu laden, erlauben Sie die passende Kategorie in den Cookie-Einstellungen.",
42
+ vendorsHeading: "Was diese Website verwendet",
43
+ whatWeUse: "Was wir verwenden"
44
+ },
45
+ fr: {
46
+ acceptAll: "Tout accepter",
47
+ rejectNonEssential: "Refuser les non essentiels",
48
+ customize: "Personnaliser",
49
+ saveChoices: "Enregistrer",
50
+ close: "Fermer",
51
+ essential: "Essentiels",
52
+ functional: "Fonctionnels",
53
+ analytics: "Mesure d’audience",
54
+ marketing: "Marketing",
55
+ essentialNote: "Toujours actifs — nécessaires au site et à ce choix.",
56
+ privacyPolicy: "Politique de confidentialité",
57
+ cookiePolicy: "Politique cookies",
58
+ cookieSettings: "Paramètres des cookies et de confidentialité",
59
+ loadEmbed: "Autoriser les contenus intégrés",
60
+ embedBlocked: "Ce contenu est bloqué tant que vous n’avez pas autorisé la catégorie correspondante.",
61
+ embedNeedConsent: "Pour charger ce contenu, autorisez la catégorie correspondante dans les paramètres des cookies.",
62
+ vendorsHeading: "Ce que ce site utilise",
63
+ whatWeUse: "Ce que nous utilisons"
64
+ },
65
+ es: {
66
+ acceptAll: "Aceptar todo",
67
+ rejectNonEssential: "Rechazar no esenciales",
68
+ customize: "Personalizar",
69
+ saveChoices: "Guardar",
70
+ close: "Cerrar",
71
+ essential: "Esenciales",
72
+ functional: "Funcionales",
73
+ analytics: "Analítica",
74
+ marketing: "Marketing",
75
+ essentialNote: "Siempre activos — necesarios para el sitio y esta elección.",
76
+ privacyPolicy: "Política de privacidad",
77
+ cookiePolicy: "Política de cookies",
78
+ cookieSettings: "Ajustes de cookies y privacidad",
79
+ loadEmbed: "Permitir incrustaciones y cargar",
80
+ embedBlocked: "Este contenido está bloqueado hasta que permita la categoría correspondiente.",
81
+ embedNeedConsent: "Para cargar este contenido, permita la categoría correspondiente en los ajustes de cookies.",
82
+ vendorsHeading: "Qué usa este sitio",
83
+ whatWeUse: "Qué usamos"
84
+ },
85
+ it: {
86
+ acceptAll: "Accetta tutto",
87
+ rejectNonEssential: "Rifiuta non essenziali",
88
+ customize: "Personalizza",
89
+ saveChoices: "Salva",
90
+ close: "Chiudi",
91
+ essential: "Essenziali",
92
+ functional: "Funzionali",
93
+ analytics: "Statistiche",
94
+ marketing: "Marketing",
95
+ essentialNote: "Sempre attivi — necessari al sito e a questa scelta.",
96
+ privacyPolicy: "Informativa sulla privacy",
97
+ cookiePolicy: "Informativa cookie",
98
+ cookieSettings: "Impostazioni cookie e privacy",
99
+ loadEmbed: "Consenti gli embed e carica",
100
+ embedBlocked: "Questo contenuto è bloccato finché non consenti la categoria corrispondente.",
101
+ embedNeedConsent: "Per caricare questo contenuto, consenti la categoria corrispondente nelle impostazioni cookie.",
102
+ vendorsHeading: "Cosa usa questo sito",
103
+ whatWeUse: "Cosa usiamo"
104
+ },
105
+ nl: {
106
+ acceptAll: "Alles accepteren",
107
+ rejectNonEssential: "Niet-essentieel weigeren",
108
+ customize: "Aanpassen",
109
+ saveChoices: "Opslaan",
110
+ close: "Sluiten",
111
+ essential: "Essentieel",
112
+ functional: "Functioneel",
113
+ analytics: "Statistieken",
114
+ marketing: "Marketing",
115
+ essentialNote: "Altijd aan — nodig voor de site en deze keuze.",
116
+ privacyPolicy: "Privacybeleid",
117
+ cookiePolicy: "Cookiebeleid",
118
+ cookieSettings: "Cookie- en privacyinstellingen",
119
+ loadEmbed: "Embeds toestaan en laden",
120
+ embedBlocked: "Deze embed is geblokkeerd tot u de bijbehorende categorie toestaat.",
121
+ embedNeedConsent: "Om deze embed te laden, sta de bijbehorende categorie toe in de cookie-instellingen.",
122
+ vendorsHeading: "Wat deze site gebruikt",
123
+ whatWeUse: "Wat wij gebruiken"
124
+ },
125
+ pt: {
126
+ acceptAll: "Aceitar tudo",
127
+ rejectNonEssential: "Recusar não essenciais",
128
+ customize: "Personalizar",
129
+ saveChoices: "Guardar",
130
+ close: "Fechar",
131
+ essential: "Essenciais",
132
+ functional: "Funcionais",
133
+ analytics: "Analítica",
134
+ marketing: "Marketing",
135
+ essentialNote: "Sempre ativos — necessários para o site e esta escolha.",
136
+ privacyPolicy: "Política de privacidade",
137
+ cookiePolicy: "Política de cookies",
138
+ cookieSettings: "Definições de cookies e privacidade",
139
+ loadEmbed: "Permitir embeds e carregar",
140
+ embedBlocked: "Este conteúdo está bloqueado até autorizar a categoria correspondente.",
141
+ embedNeedConsent: "Para carregar este conteúdo, autorize a categoria correspondente nas definições de cookies.",
142
+ vendorsHeading: "O que este site usa",
143
+ whatWeUse: "O que usamos"
144
+ },
145
+ pl: {
146
+ acceptAll: "Zaakceptuj wszystkie",
147
+ rejectNonEssential: "Odrzuć niepotrzebne",
148
+ customize: "Dostosuj",
149
+ saveChoices: "Zapisz",
150
+ close: "Zamknij",
151
+ essential: "Niezbędne",
152
+ functional: "Funkcjonalne",
153
+ analytics: "Analityczne",
154
+ marketing: "Marketingowe",
155
+ essentialNote: "Zawsze włączone — potrzebne do działania witryny i zapamiętania wyboru.",
156
+ privacyPolicy: "Polityka prywatności",
157
+ cookiePolicy: "Polityka cookies",
158
+ cookieSettings: "Ustawienia plików cookie i prywatności",
159
+ loadEmbed: "Zezwól na osadzenia i wczytaj",
160
+ embedBlocked: "Ta treść jest zablokowana, dopóki nie zezwolisz na odpowiednią kategorię.",
161
+ embedNeedConsent: "Aby wczytać tę treść, zezwól na odpowiednią kategorię w ustawieniach plików cookie.",
162
+ vendorsHeading: "Czego używa ta witryna",
163
+ whatWeUse: "Czego używamy"
164
+ }
165
+ };
166
+ function isLocaleKey(s) {
167
+ return LOCALE_KEY.test(s.trim());
168
+ }
169
+ /** `de-DE` → `["de-de", "de"]` (case-normalized). */
170
+ function localeFallbackKeys(locale) {
171
+ if (!locale) return [];
172
+ const t = locale.trim();
173
+ if (!t || t.length > 16) return [];
174
+ const lower = t.toLowerCase();
175
+ const parts = lower.split("-");
176
+ const keys = [];
177
+ if (parts.length >= 2) keys.push(`${parts[0]}-${parts[1]}`);
178
+ keys.push(parts[0] ?? lower);
179
+ return keys.filter((k, i, a) => isLocaleKey(k) && a.indexOf(k) === i);
180
+ }
181
+ function chromeForLocale(locale) {
182
+ for (const key of localeFallbackKeys(locale)) {
183
+ const found = CHROME[key] ?? CHROME[key.split("-")[0] ?? ""];
184
+ if (found) return found;
185
+ }
186
+ return CHROME["en"];
187
+ }
188
+ function parseLocaleOverrides(raw) {
189
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
190
+ const out = {};
191
+ for (const [k, v] of Object.entries(raw)) {
192
+ if (!isLocaleKey(k)) continue;
193
+ if (!v || typeof v !== "object" || Array.isArray(v)) continue;
194
+ const o = v;
195
+ const entry = {};
196
+ if (typeof o.bannerTitle === "string") entry.bannerTitle = o.bannerTitle;
197
+ if (typeof o.bannerMessage === "string") entry.bannerMessage = o.bannerMessage;
198
+ if (entry.bannerTitle !== void 0 || entry.bannerMessage !== void 0) out[k.toLowerCase()] = entry;
199
+ if (Object.keys(out).length >= 32) break;
200
+ }
201
+ return out;
202
+ }
203
+ function parseLocaleOverridesText(text) {
204
+ const t = text.trim();
205
+ if (!t) return {};
206
+ let parsed;
207
+ try {
208
+ parsed = JSON.parse(t);
209
+ } catch {
210
+ throw new Error("Translations must be JSON: {\"de\":{\"bannerTitle\":\"…\",\"bannerMessage\":\"…\"}}.");
211
+ }
212
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Translations JSON must be an object keyed by locale (e.g. de, fr).");
213
+ for (const key of Object.keys(parsed)) if (!isLocaleKey(key)) throw new Error(`Invalid locale key "${key}". Use codes like en, de, or pt-BR.`);
214
+ return parseLocaleOverrides(parsed);
215
+ }
216
+ function resolveBannerCopy(defaults, overrides, locale) {
217
+ let title = defaults.bannerTitle;
218
+ let message = defaults.bannerMessage;
219
+ for (const key of localeFallbackKeys(locale)) {
220
+ const o = overrides[key] ?? overrides[key.split("-")[0] ?? ""];
221
+ if (!o) continue;
222
+ if (typeof o.bannerTitle === "string" && o.bannerTitle.trim()) title = o.bannerTitle;
223
+ if (typeof o.bannerMessage === "string" && o.bannerMessage.trim()) message = o.bannerMessage;
224
+ break;
225
+ }
226
+ return {
227
+ bannerTitle: title,
228
+ bannerMessage: message
229
+ };
230
+ }
231
+ //#endregion
232
+ //#region src/ids.ts
233
+ const HOSTNAME$1 = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
234
+ const FATHOM_ID = /^[A-Za-z0-9]{4,32}$/;
235
+ const UMAMI_ID = /^[A-Za-z0-9-]{8,64}$/;
236
+ const GA4_ID = /^G-[A-Z0-9]{4,14}$/;
237
+ const GTM_ID = /^GTM-[A-Z0-9]{4,12}$/;
238
+ function isHostname$1(s) {
239
+ const t = s.trim().toLowerCase();
240
+ if (!t || t.length > 253) return false;
241
+ if (t === "localhost" || t.endsWith(".localhost")) return false;
242
+ return HOSTNAME$1.test(t);
243
+ }
244
+ function isGa4Id(s) {
245
+ return GA4_ID.test(s.trim());
246
+ }
247
+ function isGtmId(s) {
248
+ return GTM_ID.test(s.trim());
249
+ }
250
+ function isFathomId(s) {
251
+ return FATHOM_ID.test(s.trim());
252
+ }
253
+ function isUmamiId(s) {
254
+ return UMAMI_ID.test(s.trim());
255
+ }
256
+ //#endregion
257
+ //#region src/security.ts
258
+ /** Hosts EmPrivacy may load as <script src> for built-in presets. */
259
+ const PRESET_SCRIPT_HOSTS = /* @__PURE__ */ new Set([
260
+ "static.cloudflareinsights.com",
261
+ "plausible.io",
262
+ "cdn.usefathom.com",
263
+ "scripts.simpleanalyticscdn.com",
264
+ "www.googletagmanager.com",
265
+ "googletagmanager.com"
266
+ ]);
267
+ const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
268
+ function normalizeCookieMaxAgeDays(n, fallback = 180) {
269
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1) return fallback;
270
+ return Math.min(365, Math.max(1, Math.round(n)));
271
+ }
272
+ function parseCookieMaxAgeDays(s, fallback = 180) {
273
+ const t = s.trim();
274
+ if (!t) return fallback;
275
+ if (!/^\d{1,3}$/.test(t)) throw new Error(`Consent cookie lifetime must be 1–365 days.`);
276
+ const n = Number(t);
277
+ if (n < 1 || n > 365) throw new Error(`Consent cookie lifetime must be 1–365 days.`);
278
+ return n;
279
+ }
280
+ function cookieMaxAgeSeconds(days) {
281
+ return normalizeCookieMaxAgeDays(days) * 24 * 60 * 60;
282
+ }
283
+ function hasUnsafeUrlChars$1(s) {
284
+ return /[\u0000-\u001F\u007F\s]/.test(s);
285
+ }
286
+ function parseStrictHttpsUrl(raw) {
287
+ const t = raw.trim();
288
+ if (!t || t.length > 2048 || hasUnsafeUrlChars$1(t)) return null;
289
+ if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
290
+ try {
291
+ const u = new URL(t);
292
+ if (u.protocol !== "https:") return null;
293
+ if (u.username || u.password) return null;
294
+ const host = u.hostname.toLowerCase();
295
+ if (host === "localhost" || host.endsWith(".localhost")) return null;
296
+ if (host === "127.0.0.1" || host === "::1" || host.endsWith(".local")) return null;
297
+ return u;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ function isHostname(s) {
303
+ const t = s.trim().toLowerCase();
304
+ if (!t || t.length > 253) return false;
305
+ if (t === "localhost" || t.endsWith(".localhost")) return false;
306
+ return HOSTNAME.test(t);
307
+ }
308
+ function parseHostnameAllowlist(text) {
309
+ const lines = text.split(/\r?\n/).map((l) => l.trim().toLowerCase()).filter(Boolean);
310
+ const out = [];
311
+ for (const line of lines) {
312
+ if (!isHostname(line)) throw new Error(`Invalid script host allowlist entry "${line}". Use hostnames like cdn.example.com.`);
313
+ if (!out.includes(line)) out.push(line);
314
+ if (out.length > 50) throw new Error("Script host allowlist is too long (max 50).");
315
+ }
316
+ return out;
317
+ }
318
+ function isSafeHttpsScriptUrl(raw) {
319
+ const u = parseStrictHttpsUrl(raw);
320
+ if (!u) return false;
321
+ if (u.hash) return false;
322
+ return true;
323
+ }
324
+ function isScriptHostAllowed(raw, opts) {
325
+ const u = parseStrictHttpsUrl(raw);
326
+ if (!u) return false;
327
+ const host = u.hostname.toLowerCase();
328
+ if (opts.allowPresets && PRESET_SCRIPT_HOSTS.has(host)) return true;
329
+ if (opts.allowlist.length === 0) return true;
330
+ return opts.allowlist.includes(host);
331
+ }
332
+ /** Optional SRI: `https://cdn.example/a.js sha384-...` or `... integrity=sha384-...`. */
333
+ function parseScriptUrlWithIntegrity(line) {
334
+ const t = line.trim();
335
+ if (!t) return {
336
+ src: "",
337
+ integrity: null
338
+ };
339
+ const integrityEq = /\sintegrity=(sha(?:256|384|512)-[A-Za-z0-9+/=]+)$/i.exec(t);
340
+ if (integrityEq) return {
341
+ src: t.slice(0, integrityEq.index).trim(),
342
+ integrity: integrityEq[1] ?? null
343
+ };
344
+ const spaced = /^(https:\/\/\S+)\s+(sha(?:256|384|512)-[A-Za-z0-9+/=]+)$/i.exec(t);
345
+ if (spaced) return {
346
+ src: spaced[1] ?? "",
347
+ integrity: spaced[2] ?? null
348
+ };
349
+ return {
350
+ src: t,
351
+ integrity: null
352
+ };
353
+ }
354
+ function isValidIntegrity(s) {
355
+ if (!s) return true;
356
+ return /^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(s);
357
+ }
358
+ /**
359
+ * Same-origin guard for public state-changing plugin routes.
360
+ * Requires Origin matching the site, or Sec-Fetch-Site: same-origin.
361
+ */
362
+ function assertSameOriginMutation(request, expectedOrigin) {
363
+ const origin = request.headers.get("origin");
364
+ if (origin) {
365
+ if (origin !== expectedOrigin) return new Response("forbidden", { status: 403 });
366
+ return null;
367
+ }
368
+ if ((request.headers.get("sec-fetch-site") ?? "").toLowerCase() === "same-origin") return null;
369
+ return new Response("forbidden", { status: 403 });
370
+ }
371
+ /** Coarse hour bucket for rate limiting (UTC). */
372
+ function rateHourKey(now = /* @__PURE__ */ new Date()) {
373
+ return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}${String(now.getUTCHours()).padStart(2, "0")}`;
374
+ }
375
+ //#endregion
376
+ //#region src/theme.ts
377
+ /** Hex colors only — rejects CSS injection via theme fields. */
378
+ const HEX = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
379
+ const DEFAULT_THEME = {
380
+ bg: "#111111",
381
+ text: "#eeeeee",
382
+ accent: "#3b82f6",
383
+ radiusPx: 6
384
+ };
385
+ function isSafeHexColor(s) {
386
+ return HEX.test(s.trim());
387
+ }
388
+ function normalizeHexColor(s, fallback) {
389
+ const t = s.trim();
390
+ return isSafeHexColor(t) ? t : fallback;
391
+ }
392
+ function normalizeRadiusPx(n, fallback) {
393
+ if (typeof n !== "number" || !Number.isFinite(n)) return fallback;
394
+ return Math.min(24, Math.max(0, Math.round(n)));
395
+ }
396
+ function parseRadiusInput(s, fallback) {
397
+ const t = s.trim();
398
+ if (!t) return fallback;
399
+ if (!/^\d{1,2}$/.test(t)) throw new Error("Corner radius must be a whole number from 0 to 24.");
400
+ const n = Number(t);
401
+ if (n > 24) throw new Error("Corner radius must be a whole number from 0 to 24.");
402
+ return normalizeRadiusPx(n, fallback);
403
+ }
404
+ //#endregion
4
405
  //#region src/config.ts
5
406
  /**
6
407
  * EmPrivacy KV document (`consent:config`) and shared helpers.
@@ -10,18 +411,40 @@ const COOKIE_NAME = "emprivacy_cc";
10
411
  const PLUGIN_ID = "emprivacy";
11
412
  const DEFAULT_CONFIG = {
12
413
  bannerTitle: "Cookies & privacy",
13
- bannerMessage: "We use cookies to run this site and optionally for analytics and marketing. You can accept or customize your choices.",
414
+ bannerMessage: "We use cookies to run this site and optionally for functional embeds, analytics, and marketing. You can accept or customize your choices.",
14
415
  privacyPolicyUrl: "/privacy",
15
416
  cookiePolicyUrl: "",
16
417
  strictDefaults: true,
17
418
  policyVersion: "1",
18
419
  cloudflareWebAnalyticsToken: "",
19
420
  analyticsProvider: "cloudflare",
421
+ analyticsId: "",
422
+ umamiScriptUrl: "",
20
423
  analyticsScriptUrls: [],
21
424
  marketingScriptUrls: [],
425
+ scriptIntegrity: {},
426
+ scriptHostAllowlist: [],
427
+ cookieMaxAgeDays: 180,
22
428
  googleConsentMode: false,
23
- logConsentToServer: false
429
+ logConsentToServer: false,
430
+ embedCategory: "marketing",
431
+ gateEmbeds: true,
432
+ hideBannerOnPolicyPages: true,
433
+ defaultLocale: "en",
434
+ localeOverrides: {},
435
+ theme: { ...DEFAULT_THEME }
24
436
  };
437
+ const ANALYTICS_PROVIDERS = [
438
+ "cloudflare",
439
+ "none",
440
+ "custom",
441
+ "plausible",
442
+ "fathom",
443
+ "umami",
444
+ "simpleanalytics",
445
+ "ga4",
446
+ "gtm"
447
+ ];
25
448
  function hasUnsafeUrlChars(s) {
26
449
  return /[\u0000-\u001F\u007F\s]/.test(s);
27
450
  }
@@ -40,11 +463,14 @@ function isHttpsUrl(s) {
40
463
  }
41
464
  }
42
465
  function invalidScriptUrlError(kind, u) {
43
- if (u.includes("<") || u.includes(">")) return `Invalid ${kind} script URL (https only). Paste one full https:// URL per line (the script src only), not a <script> tag or HTML comment. Example: https://static.cloudflareinsights.com/beacon.min.js. The invalid line was: ${u}`;
466
+ if (u.includes("<") || u.includes(">")) return `Invalid ${kind} script URL (https only). Paste one full https:// URL per line (the script src only), not a <script> tag or HTML comment. The invalid line was: ${u}`;
44
467
  return `Invalid ${kind} script URL (https only): ${u}`;
45
468
  }
46
469
  function isAnalyticsProvider(s) {
47
- return s === "cloudflare" || s === "none" || s === "custom";
470
+ return ANALYTICS_PROVIDERS.includes(s);
471
+ }
472
+ function isEmbedCategory(s) {
473
+ return s === "functional" || s === "marketing";
48
474
  }
49
475
  /** Reject characters that would break JSON or attributes; allow typical Cloudflare site token strings. */
50
476
  function assertValidCloudflareToken(t) {
@@ -89,8 +515,34 @@ function assertLength(label, s, min, max) {
89
515
  function assertOptionalLength(label, s, max) {
90
516
  if (s.trim().length > max) throw new Error(`${label} is too long.`);
91
517
  }
518
+ function pathnameFromHref(href) {
519
+ const t = href.trim();
520
+ if (!t) return null;
521
+ if (isRootRelativeSitePath(t)) return t.split(/[?#]/)[0] ?? t;
522
+ try {
523
+ return new URL(t).pathname.replace(/\/+$/, "") || "/";
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ /** True when the public page is the configured privacy or cookie policy. */
529
+ function isPolicyPagePath(pagePath, cfg) {
530
+ const raw = pagePath.trim();
531
+ if (!raw) return false;
532
+ const current = raw.split(/[?#]/)[0]?.replace(/\/+$/, "") || "/";
533
+ for (const stored of [cfg.privacyPolicyUrl, cfg.cookiePolicyUrl]) {
534
+ const pn = pathnameFromHref(stored);
535
+ if (!pn) continue;
536
+ if (current === pn) return true;
537
+ if (current.endsWith(pn) && pn !== "/") return true;
538
+ }
539
+ return false;
540
+ }
92
541
  function normalizeConfig(raw) {
93
- if (typeof raw !== "object" || raw === null) return { ...DEFAULT_CONFIG };
542
+ if (typeof raw !== "object" || raw === null) return {
543
+ ...DEFAULT_CONFIG,
544
+ theme: { ...DEFAULT_THEME }
545
+ };
94
546
  const o = raw;
95
547
  const analytics = Array.isArray(o.analyticsScriptUrls) ? o.analyticsScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.analyticsScriptUrls;
96
548
  const marketing = Array.isArray(o.marketingScriptUrls) ? o.marketingScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.marketingScriptUrls;
@@ -100,6 +552,15 @@ function normalizeConfig(raw) {
100
552
  if (isAnalyticsProvider(apCandidate)) analyticsProvider = apCandidate;
101
553
  else if (analytics.length > 0) analyticsProvider = "custom";
102
554
  else analyticsProvider = DEFAULT_CONFIG.analyticsProvider;
555
+ const themeRaw = o.theme && typeof o.theme === "object" ? o.theme : {};
556
+ const embedRaw = typeof o.embedCategory === "string" ? o.embedCategory.trim() : "";
557
+ const integrityRaw = o.scriptIntegrity && typeof o.scriptIntegrity === "object" && !Array.isArray(o.scriptIntegrity) ? o.scriptIntegrity : {};
558
+ const scriptIntegrity = {};
559
+ for (const [k, v] of Object.entries(integrityRaw)) {
560
+ if (typeof k === "string" && typeof v === "string" && isValidIntegrity(v) && isSafeHttpsScriptUrl(k)) scriptIntegrity[k] = v;
561
+ if (Object.keys(scriptIntegrity).length >= 100) break;
562
+ }
563
+ const scriptHostAllowlist = (Array.isArray(o.scriptHostAllowlist) ? o.scriptHostAllowlist.filter((x) => typeof x === "string") : []).map((h) => h.trim().toLowerCase()).filter((h) => isHostname$1(h)).slice(0, 50);
103
564
  return {
104
565
  bannerTitle: typeof o.bannerTitle === "string" ? o.bannerTitle : DEFAULT_CONFIG.bannerTitle,
105
566
  bannerMessage: typeof o.bannerMessage === "string" ? o.bannerMessage : DEFAULT_CONFIG.bannerMessage,
@@ -109,12 +570,53 @@ function normalizeConfig(raw) {
109
570
  policyVersion: typeof o.policyVersion === "string" && o.policyVersion.length > 0 ? o.policyVersion : DEFAULT_CONFIG.policyVersion,
110
571
  cloudflareWebAnalyticsToken: cfTok,
111
572
  analyticsProvider,
573
+ analyticsId: typeof o.analyticsId === "string" ? o.analyticsId : "",
574
+ umamiScriptUrl: typeof o.umamiScriptUrl === "string" ? o.umamiScriptUrl : "",
112
575
  analyticsScriptUrls: analytics,
113
576
  marketingScriptUrls: marketing,
577
+ scriptIntegrity,
578
+ scriptHostAllowlist,
579
+ cookieMaxAgeDays: normalizeCookieMaxAgeDays(o.cookieMaxAgeDays, DEFAULT_CONFIG.cookieMaxAgeDays),
114
580
  googleConsentMode: typeof o.googleConsentMode === "boolean" ? o.googleConsentMode : DEFAULT_CONFIG.googleConsentMode,
115
- logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer
581
+ logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer,
582
+ embedCategory: isEmbedCategory(embedRaw) ? embedRaw : DEFAULT_CONFIG.embedCategory,
583
+ gateEmbeds: typeof o.gateEmbeds === "boolean" ? o.gateEmbeds : DEFAULT_CONFIG.gateEmbeds,
584
+ hideBannerOnPolicyPages: typeof o.hideBannerOnPolicyPages === "boolean" ? o.hideBannerOnPolicyPages : DEFAULT_CONFIG.hideBannerOnPolicyPages,
585
+ defaultLocale: typeof o.defaultLocale === "string" && o.defaultLocale ? o.defaultLocale : DEFAULT_CONFIG.defaultLocale,
586
+ localeOverrides: parseLocaleOverrides(o.localeOverrides),
587
+ theme: {
588
+ bg: normalizeHexColor(typeof themeRaw.bg === "string" ? themeRaw.bg : "", DEFAULT_THEME.bg),
589
+ text: normalizeHexColor(typeof themeRaw.text === "string" ? themeRaw.text : "", DEFAULT_THEME.text),
590
+ accent: normalizeHexColor(typeof themeRaw.accent === "string" ? themeRaw.accent : "", DEFAULT_THEME.accent),
591
+ radiusPx: normalizeRadiusPx(themeRaw.radiusPx, DEFAULT_THEME.radiusPx)
592
+ }
116
593
  };
117
594
  }
595
+ function assertAnalyticsId(provider, id, umamiScriptUrl) {
596
+ const t = id.trim();
597
+ if (provider === "plausible") {
598
+ if (!isHostname$1(t)) throw new Error("Plausible domain must be a hostname like example.com (no https://).");
599
+ return;
600
+ }
601
+ if (provider === "fathom") {
602
+ if (!isFathomId(t)) throw new Error("Fathom site ID must be 4–32 letters or digits.");
603
+ return;
604
+ }
605
+ if (provider === "umami") {
606
+ if (!isUmamiId(t)) throw new Error("Umami website ID looks invalid.");
607
+ if (!isHttpsUrl(umamiScriptUrl)) throw new Error("Umami requires the https script URL (Cloud or your self-hosted tracker).");
608
+ return;
609
+ }
610
+ if (provider === "ga4") {
611
+ if (!isGa4Id(t)) throw new Error("GA4 measurement ID must look like G-XXXXXXXX.");
612
+ return;
613
+ }
614
+ if (provider === "gtm") {
615
+ if (!isGtmId(t)) throw new Error("Google Tag Manager ID must look like GTM-XXXX.");
616
+ return;
617
+ }
618
+ if (provider === "simpleanalytics" && t && !isHostname$1(t)) throw new Error("Simple Analytics hostname must be a domain like example.com, or empty.");
619
+ }
118
620
  /** Validate admin-submitted fields; throws an Error with a short message on failure */
119
621
  function assertValidSavedConfig(input) {
120
622
  assertLength("Policy version", input.policyVersion, 1, 64);
@@ -128,27 +630,60 @@ function assertValidSavedConfig(input) {
128
630
  if (cookiePolicyUrl.length > 2048) throw new Error("Cookie policy URL/path is too long.");
129
631
  if (!cookiePolicyUrl) cookiePolicyUrl = DEFAULT_CONFIG.cookiePolicyUrl;
130
632
  const ap = input.analyticsPlatform.trim() || "cloudflare";
131
- if (!isAnalyticsProvider(ap)) throw new Error("Analytics: choose a supported option (Cloudflare, None, or Custom).");
633
+ if (!isAnalyticsProvider(ap)) throw new Error("Analytics: choose a supported platform.");
132
634
  const tokenTrim = input.cloudflareToken.trim();
133
635
  if (ap === "cloudflare") assertValidCloudflareToken(tokenTrim);
636
+ const umamiScriptUrl = input.umamiScriptUrl.trim();
637
+ if (ap === "umami" && umamiScriptUrl && !isHttpsUrl(umamiScriptUrl)) throw new Error(invalidScriptUrlError("umami", umamiScriptUrl));
638
+ if (ap !== "none" && ap !== "custom" && ap !== "cloudflare" && ap !== "simpleanalytics" && ap !== "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
639
+ else if (ap === "simpleanalytics" && input.analyticsId.trim()) assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
640
+ else if (ap === "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
641
+ const scriptHostAllowlist = parseHostnameAllowlist(input.scriptHostAllowlistText);
642
+ const cookieMaxAgeDays = parseCookieMaxAgeDays(input.cookieMaxAgeDays, 180);
643
+ const scriptIntegrity = {};
644
+ const assertScriptLine = (kind, line) => {
645
+ const { src, integrity } = parseScriptUrlWithIntegrity(line);
646
+ if (!src || src.length > 2048) throw new Error(invalidScriptUrlError(kind, line));
647
+ if (!isSafeHttpsScriptUrl(src)) throw new Error(invalidScriptUrlError(kind, src));
648
+ if (integrity && !isValidIntegrity(integrity)) throw new Error(`Invalid SRI for ${kind} script (expected sha256-|sha384-|sha512-…).`);
649
+ if (!isScriptHostAllowed(src, {
650
+ allowlist: scriptHostAllowlist,
651
+ allowPresets: false
652
+ })) throw new Error(`${kind} script host is not on the allowlist. Add the hostname or clear the allowlist.`);
653
+ if (integrity) scriptIntegrity[src] = integrity;
654
+ return src;
655
+ };
656
+ if (ap === "umami" && umamiScriptUrl) {
657
+ if (!isScriptHostAllowed(umamiScriptUrl, {
658
+ allowlist: scriptHostAllowlist,
659
+ allowPresets: false
660
+ })) throw new Error("Umami script host is not on the allowlist. Add the hostname or clear the allowlist.");
661
+ }
134
662
  const analyticsScriptUrls = [];
135
663
  if (ap === "custom") {
136
664
  const list = parseUrlList(input.analyticsUrlsText);
137
665
  if (list.length > 50) throw new Error("Analytics script URL list is too long (max 50).");
138
- for (const u of list) {
139
- if (u.length > 2048) throw new Error(invalidScriptUrlError("analytics", u));
140
- if (!isHttpsUrl(u)) throw new Error(invalidScriptUrlError("analytics", u));
141
- analyticsScriptUrls.push(u);
142
- }
666
+ for (const u of list) analyticsScriptUrls.push(assertScriptLine("analytics", u));
143
667
  }
144
668
  const marketingScriptUrls = [];
145
669
  const mlist = parseUrlList(input.marketingUrlsText);
146
670
  if (mlist.length > 50) throw new Error("Marketing script URL list is too long (max 50).");
147
- for (const u of mlist) {
148
- if (u.length > 2048) throw new Error(invalidScriptUrlError("marketing", u));
149
- if (!isHttpsUrl(u)) throw new Error(invalidScriptUrlError("marketing", u));
150
- marketingScriptUrls.push(u);
671
+ for (const u of mlist) marketingScriptUrls.push(assertScriptLine("marketing", u));
672
+ const embedCategory = input.embedCategory.trim() || "marketing";
673
+ if (!isEmbedCategory(embedCategory)) throw new Error("Embeds must require Functional or Marketing consent.");
674
+ const defaultLocale = input.defaultLocale.trim().toLowerCase() || "en";
675
+ if (!/^[a-z]{2,3}(?:-[a-z]{2})?$/.test(defaultLocale)) throw new Error("Default locale must look like en or pt-BR.");
676
+ const localeOverrides = parseLocaleOverridesText(input.localeOverridesText);
677
+ for (const pack of Object.values(localeOverrides)) {
678
+ if (pack.bannerTitle !== void 0) assertOptionalLength("Translated banner title", pack.bannerTitle, 120);
679
+ if (pack.bannerMessage !== void 0) assertOptionalLength("Translated short notice", pack.bannerMessage, 600);
151
680
  }
681
+ const themeBg = input.themeBg.trim() || DEFAULT_THEME.bg;
682
+ const themeText = input.themeText.trim() || DEFAULT_THEME.text;
683
+ const themeAccent = input.themeAccent.trim() || DEFAULT_THEME.accent;
684
+ if (input.themeBg.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeBg)) throw new Error("Banner background must be a hex color like #111111.");
685
+ if (input.themeText.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeText)) throw new Error("Banner text color must be a hex color like #eeeeee.");
686
+ if (input.themeAccent.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeAccent)) throw new Error("Banner accent color must be a hex color like #3b82f6.");
152
687
  return normalizeConfig({
153
688
  bannerTitle: input.bannerTitle.trim() || DEFAULT_CONFIG.bannerTitle,
154
689
  bannerMessage: input.bannerMessage.trim() || DEFAULT_CONFIG.bannerMessage,
@@ -158,73 +693,156 @@ function assertValidSavedConfig(input) {
158
693
  policyVersion: input.policyVersion.trim(),
159
694
  analyticsProvider: ap,
160
695
  cloudflareWebAnalyticsToken: ap === "cloudflare" ? tokenTrim : "",
696
+ analyticsId: ap === "cloudflare" || ap === "custom" || ap === "none" ? "" : input.analyticsId.trim(),
697
+ umamiScriptUrl: ap === "umami" ? umamiScriptUrl : "",
161
698
  analyticsScriptUrls: ap === "custom" ? analyticsScriptUrls : [],
162
699
  marketingScriptUrls,
700
+ scriptIntegrity,
701
+ scriptHostAllowlist,
702
+ cookieMaxAgeDays,
163
703
  googleConsentMode: input.googleConsentMode,
164
- logConsentToServer: input.logConsentToServer
704
+ logConsentToServer: input.logConsentToServer,
705
+ embedCategory,
706
+ gateEmbeds: input.gateEmbeds,
707
+ hideBannerOnPolicyPages: input.hideBannerOnPolicyPages,
708
+ defaultLocale,
709
+ localeOverrides,
710
+ theme: {
711
+ bg: themeBg,
712
+ text: themeText,
713
+ accent: themeAccent,
714
+ radiusPx: parseRadiusInput(input.themeRadius, DEFAULT_THEME.radiusPx)
715
+ }
165
716
  });
166
717
  }
167
-
168
- //#endregion
169
- //#region src/version.ts
170
- const VERSION = "2.0.0";
171
-
718
+ /** Safe to embed in `<script type="application/json">` (breakout-safe) */
719
+ function jsonForHtmlScript(value) {
720
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => {
721
+ switch (c) {
722
+ case "<": return "\\u003c";
723
+ case ">": return "\\u003e";
724
+ case "&": return "\\u0026";
725
+ case "\u2028": return "\\u2028";
726
+ case "\u2029": return "\\u2029";
727
+ default: return c;
728
+ }
729
+ });
730
+ }
731
+ function resolvePublicCopy(cfg, locale) {
732
+ const copy = resolveBannerCopy({
733
+ bannerTitle: cfg.bannerTitle,
734
+ bannerMessage: cfg.bannerMessage
735
+ }, cfg.localeOverrides, locale ?? cfg.defaultLocale);
736
+ return {
737
+ bannerTitle: copy.bannerTitle,
738
+ bannerMessage: copy.bannerMessage,
739
+ ui: chromeForLocale(locale ?? cfg.defaultLocale)
740
+ };
741
+ }
172
742
  //#endregion
173
- //#region src/runtime.ts
174
- const ADMIN_SETTINGS_PATH = "/settings";
175
- const SAVE_ACTION_ID = "emprivacy-save";
176
- const recordInput = z.object({
177
- policyVersion: z.string().trim().min(1).max(64),
178
- analytics: z.boolean(),
179
- marketing: z.boolean()
180
- });
181
- async function loadConfig(ctx) {
182
- const raw = await ctx.kv.get(KV_KEY);
183
- if (!raw) return normalizeConfig({});
184
- try {
185
- return normalizeConfig(JSON.parse(raw));
186
- } catch {
187
- return normalizeConfig({});
188
- }
743
+ //#region src/npm-version.ts
744
+ /** Public npm package page for this plugin. */
745
+ const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@emplugins/emprivacy";
746
+ /** npm registry metadata for the `latest` dist-tag. */
747
+ const NPM_REGISTRY_LATEST_URL = "https://registry.npmjs.org/@emplugins/emprivacy/latest";
748
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
749
+ const SUCCESS_CACHE_MS = 6e5;
750
+ const FAILURE_CACHE_MS = 6e4;
751
+ const FETCH_TIMEOUT_MS = 2500;
752
+ let cache = null;
753
+ /** Accept only a well-formed semver from untrusted registry JSON. */
754
+ function parseNpmLatestVersion(data) {
755
+ if (!data || typeof data !== "object") return null;
756
+ const version = data.version;
757
+ if (typeof version !== "string") return null;
758
+ const trimmed = version.trim();
759
+ return SEMVER.test(trimmed) ? trimmed : null;
189
760
  }
190
- async function saveConfigString(ctx, json) {
191
- await ctx.kv.set(KV_KEY, json);
761
+ function parseCore(version) {
762
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
763
+ if (!m) return null;
764
+ return [
765
+ Number(m[1]),
766
+ Number(m[2]),
767
+ Number(m[3])
768
+ ];
192
769
  }
193
- /** Path-only record URL for browser fetch (same origin). */
194
- function recordPathForSite() {
195
- return `/_emdash/api/plugins/${PLUGIN_ID}/record`;
770
+ /** True when `latest` is a higher MAJOR.MINOR.PATCH than `installed`. */
771
+ function isNewerPublished(latest, installed) {
772
+ const a = parseCore(latest);
773
+ const b = parseCore(installed);
774
+ if (!a || !b) return latest !== installed;
775
+ if (a[0] !== b[0]) return a[0] > b[0];
776
+ if (a[1] !== b[1]) return a[1] > b[1];
777
+ return a[2] > b[2];
196
778
  }
197
- /** Absolute URL for metadata / banner; paths resolved via EmDash `ctx.url()`. */
198
- function absolutePolicyHref(stored, ctx) {
199
- const r = resolvePolicyHref(stored, ctx).trim();
200
- if (!r) return null;
779
+ function pluginVersionFields(installed, latest) {
780
+ return [{
781
+ label: "Installed version",
782
+ value: installed
783
+ }, {
784
+ label: "Latest on npm",
785
+ value: latest ?? "Could not reach npm"
786
+ }];
787
+ }
788
+ function pluginVersionNote(installed, latest) {
789
+ if (latest && isNewerPublished(latest, installed)) return `A newer version is published. Update @emplugins/emprivacy to get it. ${NPM_PACKAGE_URL}`;
790
+ return `Published package: ${NPM_PACKAGE_URL}`;
791
+ }
792
+ /**
793
+ * Read the published `latest` version from the npm registry.
794
+ * Results are cached briefly so admin save/reload does not hit npm every time.
795
+ * Failures return null and never throw — the settings page must still load.
796
+ */
797
+ async function fetchLatestPublishedVersion(options) {
798
+ const now = options?.now ?? Date.now();
799
+ if (cache && now < cache.expiresAt) return cache.value;
800
+ const doFetch = options?.fetch ?? globalThis.fetch;
801
+ const timeoutMs = options?.timeoutMs ?? FETCH_TIMEOUT_MS;
201
802
  try {
202
- if (new URL(r).protocol !== "https:") return null;
203
- return r;
803
+ const response = await doFetch(NPM_REGISTRY_LATEST_URL, {
804
+ method: "GET",
805
+ headers: {
806
+ Accept: "application/json",
807
+ "User-Agent": "EmPrivacy (https://github.com/EmPlugins/EmPrivacy)"
808
+ },
809
+ signal: AbortSignal.timeout(timeoutMs)
810
+ });
811
+ if (!response.ok) {
812
+ cache = {
813
+ value: null,
814
+ expiresAt: now + FAILURE_CACHE_MS
815
+ };
816
+ return null;
817
+ }
818
+ const version = parseNpmLatestVersion(await response.json());
819
+ cache = {
820
+ value: version,
821
+ expiresAt: now + (version ? SUCCESS_CACHE_MS : FAILURE_CACHE_MS)
822
+ };
823
+ return version;
204
824
  } catch {
825
+ cache = {
826
+ value: null,
827
+ expiresAt: now + FAILURE_CACHE_MS
828
+ };
205
829
  return null;
206
830
  }
207
831
  }
208
- function buildGoogleConsentHeadScript() {
209
- return `(function(){window.dataLayer=window.dataLayer||[];function g(){window.dataLayer.push(arguments);}window.gtag=g;g("consent","default",{"analytics_storage":"denied","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied","functionality_storage":"granted","security_storage":"granted","personalization_storage":"denied"});})();`;
210
- }
832
+ //#endregion
833
+ //#region src/public-bootstrap.ts
211
834
  /**
212
- * Public-site bootstrap: no HTML from admin strings in DOM APIs beyond JSON.parse —
213
- * banner copy is applied with textContent / createTextNode.
835
+ * Public-site bootstrap. Banner copy is applied with textContent / createTextNode only.
836
+ * Embed/script URLs are re-validated in the browser (never trust DOM data-src alone).
214
837
  */
215
838
  function buildBodyBootstrap(pr) {
216
839
  return `(function(){
217
- var C=${JSON.stringify(pr).replace(/[<>&\u2028\u2029]/g, (c) => {
218
- switch (c) {
219
- case "<": return "\\u003c";
220
- case ">": return "\\u003e";
221
- case "&": return "\\u0026";
222
- case "\u2028": return "\\u2028";
223
- case "\u2029": return "\\u2029";
224
- default: return c;
225
- }
226
- })};
840
+ var C=${jsonForHtmlScript(pr)};
227
841
  var CN="${COOKIE_NAME}";
842
+ var listeners=[];
843
+ var PRESET_HOSTS={"static.cloudflareinsights.com":1,"plausible.io":1,"cdn.usefathom.com":1,"scripts.simpleanalyticscdn.com":1,"www.googletagmanager.com":1,"googletagmanager.com":1};
844
+ var YT_IFRAME=/^https:\\/\\/(?:www\\.)?youtube-nocookie\\.com\\/embed\\/[A-Za-z0-9_-]{11}\\/?$/;
845
+ var VIMEO_IFRAME=/^https:\\/\\/player\\.vimeo\\.com\\/video\\/\\d{6,12}\\/?$/;
228
846
  function readCookie(){
229
847
  try{
230
848
  var m=document.cookie.match(new RegExp("(?:^|;\\\\s*)"+CN+"=([^;]*)"));
@@ -235,44 +853,96 @@ return v;
235
853
  }
236
854
  function writeCookie(val){
237
855
  var secure=document.location.protocol==="https:"?"; Secure":"";
238
- document.cookie=CN+"="+encodeURIComponent(val)+"; Path=/; SameSite=Lax"+secure+"; Max-Age=31536000";
856
+ var maxAge=typeof C.cookieMaxAge==="number"&&C.cookieMaxAge>0?C.cookieMaxAge:15552000;
857
+ document.cookie=CN+"="+encodeURIComponent(val)+"; Path=/; SameSite=Lax"+secure+"; Max-Age="+maxAge;
239
858
  }
859
+ function flagOk(x){return x===0||x===1||x===true||x===false;}
240
860
  function parseState(s){
241
861
  try{
242
862
  var o=JSON.parse(s);
243
863
  if(!o||typeof o!=="object")return null;
244
864
  if(typeof o.v!=="string")return null;
245
865
  if(o.v!==C.policyVersion)return null;
246
- var a=o.a,m=o.m;
247
- var aOk=(a===0||a===1||a===true||a===false);
248
- var mOk=(m===0||m===1||m===true||m===false);
249
- if(!aOk||!mOk)return null;
250
- return {v:o.v,a:!!a,m:!!m};
866
+ if(!flagOk(o.a)||!flagOk(o.m)||!flagOk(o.f))return null;
867
+ return {v:o.v,essential:true,functional:!!o.f,analytics:!!o.a,marketing:!!o.m};
251
868
  }catch(e){return null;}
252
869
  }
870
+ function currentState(){return parseState(readCookie());}
871
+ function emit(st){
872
+ listeners.slice().forEach(function(fn){try{fn(st);}catch(e){}});
873
+ try{document.dispatchEvent(new CustomEvent("emprivacy:change",{detail:st}));}catch(e){}
874
+ }
253
875
  function needBanner(){
254
- var c=parseState(readCookie());
255
- if(!c)return true;
256
- return false;
876
+ if(C.hideBanner)return false;
877
+ return !parseState(readCookie());
257
878
  }
258
- function alreadyScriptSrc(u){
879
+ function parseHttps(raw){
259
880
  try{
260
- return Array.prototype.some.call(document.getElementsByTagName("script"),function(s){
261
- return s.src===u;
262
- });
263
- }catch(e){
264
- return false;
881
+ if(!raw||typeof raw!=="string"||raw.length>2048)return null;
882
+ if(/[\\u0000-\\u001F\\u007F\\s]/.test(raw))return null;
883
+ if(/%0d|%0a|%09|%0b|%0c|%20/i.test(raw))return null;
884
+ var u=new URL(raw);
885
+ if(u.protocol!=="https:")return null;
886
+ if(u.username||u.password)return null;
887
+ var h=u.hostname.toLowerCase();
888
+ if(h==="localhost"||h.endsWith(".localhost")||h==="127.0.0.1"||h==="::1")return null;
889
+ return u;
890
+ }catch(e){return null;}
265
891
  }
892
+ function hostAllowed(u,allowPresets){
893
+ var h=u.hostname.toLowerCase();
894
+ if(allowPresets&&PRESET_HOSTS[h])return true;
895
+ var list=C.scriptHostAllowlist||[];
896
+ if(!list.length)return true;
897
+ return list.indexOf(h)>=0;
266
898
  }
267
- function loadScript(src){
268
- if(alreadyScriptSrc(src))return;
899
+ function safeScriptUrl(raw,allowPresets){
900
+ var u=parseHttps(raw);
901
+ if(!u||u.hash)return null;
902
+ if(!hostAllowed(u,!!allowPresets))return null;
903
+ return u.href;
904
+ }
905
+ function allowedIframe(src){return YT_IFRAME.test(src)||VIMEO_IFRAME.test(src);}
906
+ function allowedLink(src,kind){
907
+ var u=parseHttps(src);
908
+ if(!u)return false;
909
+ var h=u.hostname.toLowerCase();
910
+ if(h==="x.com"||h==="www.x.com"||h==="twitter.com"||h==="www.twitter.com"||h==="mobile.twitter.com"){
911
+ return /\\/(?:i\\/web\\/)?status(?:es)?\\/\\d{5,20}\\/?$/i.test(u.pathname);
912
+ }
913
+ if(h==="bsky.app"||h==="www.bsky.app"){
914
+ return /^\\/profile\\/[^/]+\\/post\\/[^/]+\\/?$/.test(u.pathname);
915
+ }
916
+ if(h==="gist.github.com"){
917
+ return /^\\/[A-Za-z0-9-]{1,39}\\/[a-f0-9]{8,64}\\/?$/i.test(u.pathname);
918
+ }
919
+ if(/^\\/@[^/]+\\/\\d+\\/?$/.test(u.pathname)||/^\\/users\\/[^/]+\\/statuses\\/\\d+\\/?$/.test(u.pathname))return true;
920
+ return kind==="linkPreview";
921
+ }
922
+ function alreadyScriptSrc(u){
923
+ try{
924
+ return Array.prototype.some.call(document.getElementsByTagName("script"),function(s){return s.src===u;});
925
+ }catch(e){return false;}
926
+ }
927
+ function loadScript(src, attrs, integrity, allowPresets){
928
+ var safe=safeScriptUrl(src,allowPresets);
929
+ if(!safe||alreadyScriptSrc(safe))return;
269
930
  var e=document.createElement("script");
270
- e.src=src;e.async=true;e.referrerPolicy="no-referrer-when-downgrade";
931
+ e.src=safe;e.async=true;e.referrerPolicy="no-referrer-when-downgrade";
932
+ if(integrity&&/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity)){
933
+ e.integrity=integrity;
934
+ e.crossOrigin="anonymous";
935
+ }
936
+ if(attrs){
937
+ Object.keys(attrs).forEach(function(k){
938
+ if(/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(k)) e.setAttribute(k,String(attrs[k]));
939
+ });
940
+ }
271
941
  document.head.appendChild(e);
272
942
  }
273
943
  function loadCloudflareBeacon(token){
274
944
  var u="https://static.cloudflareinsights.com/beacon.min.js";
275
- if(!token)return;
945
+ if(!token||typeof token!=="string"||token.length>256||/[\\s<>'"&]/.test(token))return;
276
946
  if(alreadyScriptSrc(u))return;
277
947
  var e=document.createElement("script");
278
948
  e.src=u;
@@ -281,72 +951,121 @@ e.setAttribute("data-cf-beacon",JSON.stringify({token:token}));
281
951
  e.referrerPolicy="no-referrer-when-downgrade";
282
952
  document.head.appendChild(e);
283
953
  }
284
- function applyScripts(a,m){
285
- if(a){
286
- if(C.analyticsProvider==="cloudflare"){
287
- loadCloudflareBeacon(C.cloudflareToken);
288
- }else if(C.analyticsProvider==="custom"){
289
- C.analyticsScriptUrls.forEach(loadScript);
954
+ function applyAnalytics(){
955
+ var L=C.loader||{type:"none"};
956
+ if(L.type==="cloudflare") loadCloudflareBeacon(L.token);
957
+ else if(L.type==="custom") (L.scripts||[]).forEach(function(s){loadScript(s.src,null,s.integrity,false);});
958
+ else if(L.type==="plausible") loadScript(L.src,{"data-domain":L.domain},null,true);
959
+ else if(L.type==="fathom") loadScript(L.src,{"data-site":L.siteId},null,true);
960
+ else if(L.type==="umami") loadScript(L.src,{"data-website-id":L.websiteId},null,false);
961
+ else if(L.type==="simpleanalytics") loadScript(L.src,null,null,true);
962
+ else if(L.type==="ga4"){
963
+ window.dataLayer=window.dataLayer||[];
964
+ if(typeof window.gtag!=="function"){window.gtag=function(){window.dataLayer.push(arguments);};}
965
+ loadScript("https://www.googletagmanager.com/gtag/js?id="+encodeURIComponent(L.measurementId),null,null,true);
966
+ window.gtag("js",new Date());
967
+ window.gtag("config",L.measurementId);
290
968
  }
291
969
  }
292
- if(m)C.marketingScriptUrls.forEach(loadScript);
970
+ function applyMarketing(){
971
+ var urls=C.marketingScripts||[];
972
+ urls.forEach(function(s){loadScript(s.src,null,s.integrity,false);});
973
+ var L=C.loader||{type:"none"};
974
+ if(L.type==="gtm"){
975
+ window.dataLayer=window.dataLayer||[];
976
+ window.dataLayer.push({"gtm.start":Date.now(),event:"gtm.js"});
977
+ loadScript("https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(L.containerId),null,null,true);
293
978
  }
294
- function gtagUpdate(a,m){
979
+ }
980
+ function applyScripts(st){
981
+ if(st.analytics) applyAnalytics();
982
+ if(st.marketing) applyMarketing();
983
+ }
984
+ function gtagUpdate(st){
295
985
  if(!C.googleConsentMode||!window.gtag)return;
296
986
  window.gtag("consent","update",{
297
- analytics_storage:a?"granted":"denied",
298
- ad_storage:m?"granted":"denied",
299
- ad_user_data:m?"granted":"denied",
300
- ad_personalization:m?"granted":"denied",
301
- personalization_storage:m?"granted":"denied"
987
+ analytics_storage:st.analytics?"granted":"denied",
988
+ ad_storage:st.marketing?"granted":"denied",
989
+ ad_user_data:st.marketing?"granted":"denied",
990
+ ad_personalization:st.marketing?"granted":"denied",
991
+ personalization_storage:st.marketing?"granted":"denied",
992
+ functionality_storage:st.functional?"granted":"denied",
993
+ security_storage:"granted"
302
994
  });
303
995
  }
304
- function logServer(a,m){
996
+ function logServer(st){
305
997
  if(!C.logConsent)return;
306
- var p=C.recordPath;
307
- fetch(p,{
998
+ fetch(C.recordPath,{
308
999
  method:"POST",
309
1000
  credentials:"same-origin",
310
1001
  headers:{"Content-Type":"application/json"},
311
- body:JSON.stringify({policyVersion:C.policyVersion,analytics:!!a,marketing:!!m})
1002
+ body:JSON.stringify({policyVersion:C.policyVersion,functional:!!st.functional,analytics:!!st.analytics,marketing:!!st.marketing})
312
1003
  }).catch(function(){});
313
1004
  }
314
1005
  function hide(el){if(el)el.setAttribute("hidden","");el&&(el.style.display="none");}
315
1006
  function show(el){if(el)el.removeAttribute("hidden");el&&(el.style.display="");}
316
- function mount(){
317
- var root=document.getElementById("emprivacy-root");
318
- if(!root)return;
319
- var trigger=document.createElement("button");
320
- trigger.type="button";
321
- trigger.className="emprivacy-cookie-trigger";
322
- trigger.setAttribute("aria-label","Cookie and privacy settings");
323
- var ic=document.createElement("span");
324
- ic.setAttribute("aria-hidden","true");
325
- ic.className="emprivacy-cookie-trigger-icon";
326
- ic.appendChild(document.createTextNode("🍪"));
327
- trigger.appendChild(ic);
328
- function showTrigger(){
329
- trigger.removeAttribute("hidden");
1007
+ function embedAllowed(st){
1008
+ if(!C.gateEmbeds)return false;
1009
+ return C.embedCategory==="functional"?!!st.functional:!!st.marketing;
330
1010
  }
331
- function hideTrigger(){
332
- trigger.setAttribute("hidden","");
1011
+ function mountIframe(box,src){
1012
+ if(!allowedIframe(src))return;
1013
+ var f=document.createElement("iframe");
1014
+ f.src=src;
1015
+ f.setAttribute("loading","lazy");
1016
+ f.setAttribute("referrerpolicy","strict-origin-when-cross-origin");
1017
+ f.setAttribute("allow","accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share");
1018
+ f.setAttribute("allowfullscreen","");
1019
+ f.setAttribute("sandbox","allow-scripts allow-same-origin allow-presentation");
1020
+ f.setAttribute("title",box.getAttribute("data-label")||"Embed");
1021
+ f.style.width="100%";
1022
+ f.style.aspectRatio="16/9";
1023
+ f.style.border="0";
1024
+ box.replaceWith(f);
333
1025
  }
334
- function mkRow(label,id,on){
335
- var w=document.createElement("label");
336
- w.className="emprivacy-switch";
337
- var cb=document.createElement("input");
338
- cb.type="checkbox";
339
- cb.id=id;
340
- cb.checked=on;
341
- w.appendChild(cb);
342
- w.appendChild(document.createTextNode(" "+label));
343
- return {wrap:w,box:cb};
1026
+ function mountLink(box,src,label,kind){
1027
+ if(!allowedLink(src,kind))return;
1028
+ var a=document.createElement("a");
1029
+ a.href=src;
1030
+ a.rel="noopener noreferrer nofollow";
1031
+ a.target="_blank";
1032
+ a.appendChild(document.createTextNode(label||src));
1033
+ box.replaceWith(a);
1034
+ }
1035
+ function hydrateEmbeds(st){
1036
+ if(!C.gateEmbeds)return;
1037
+ var nodes=document.querySelectorAll("[data-emprivacy-embed]");
1038
+ Array.prototype.forEach.call(nodes,function(box){
1039
+ var copy=box.querySelector(".emprivacy-embed-copy");
1040
+ var loadBtn=box.querySelector("[data-emprivacy-embed-load]");
1041
+ if(copy) copy.textContent=C.ui.embedBlocked;
1042
+ if(loadBtn) loadBtn.textContent=C.ui.loadEmbed;
1043
+ if(!embedAllowed(st))return;
1044
+ var src=box.getAttribute("data-src")||"";
1045
+ var mode=box.getAttribute("data-mode")||"";
1046
+ var kind=box.getAttribute("data-emprivacy-embed")||"";
1047
+ if(mode==="iframe") mountIframe(box,src);
1048
+ else mountLink(box,src,box.getAttribute("data-label")||"",kind);
1049
+ });
1050
+ }
1051
+ function persist(a,m,f,opts){
1052
+ var st={v:C.policyVersion,essential:true,functional:!!f,analytics:!!a,marketing:!!m};
1053
+ writeCookie(JSON.stringify({v:st.v,a:st.analytics?1:0,m:st.marketing?1:0,f:st.functional?1:0}));
1054
+ logServer(st);
1055
+ gtagUpdate(st);
1056
+ emit(st);
1057
+ hydrateEmbeds(st);
1058
+ if(!opts||!opts.skipReload){
1059
+ location.reload();
1060
+ return st;
1061
+ }
1062
+ applyScripts(st);
1063
+ return st;
344
1064
  }
345
1065
  function addPolicyLinks(links){
346
1066
  function addOne(href,text){
347
1067
  if(!href)return;
348
- var ok=href.indexOf("https://")===0;
349
- if(!ok)return;
1068
+ if(!parseHttps(href))return;
350
1069
  var a=document.createElement("a");
351
1070
  a.href=href;
352
1071
  a.rel="nofollow noopener";
@@ -354,20 +1073,106 @@ a.target="_blank";
354
1073
  a.appendChild(document.createTextNode(text));
355
1074
  links.appendChild(a);
356
1075
  }
357
- addOne(C.privacyPolicyUrl,"Privacy policy");
358
- if(C.cookiePolicyUrl)addOne(C.cookiePolicyUrl,"Cookie policy");
1076
+ addOne(C.privacyPolicyUrl,C.ui.privacyPolicy);
1077
+ if(C.cookiePolicyUrl)addOne(C.cookiePolicyUrl,C.ui.cookiePolicy);
359
1078
  }
360
- function openReopenPanel(){
361
- if(root.querySelector(".emprivacy-bar--reopen"))return;
1079
+ function addVendorDisclosure(parent){
1080
+ if(!C.vendors||!C.vendors.length)return;
1081
+ var det=document.createElement("details");
1082
+ det.className="emprivacy-vendors";
1083
+ var sum=document.createElement("summary");
1084
+ sum.appendChild(document.createTextNode(C.ui.vendorsHeading));
1085
+ det.appendChild(sum);
1086
+ var ul=document.createElement("ul");
1087
+ C.vendors.forEach(function(v){
1088
+ var li=document.createElement("li");
1089
+ var strong=document.createElement("strong");
1090
+ strong.appendChild(document.createTextNode(v.name));
1091
+ li.appendChild(strong);
1092
+ li.appendChild(document.createTextNode(" ("+v.category+") — "+v.purpose));
1093
+ if(v.policyUrl&&parseHttps(v.policyUrl)){
1094
+ li.appendChild(document.createTextNode(" "));
1095
+ var a=document.createElement("a");
1096
+ a.href=v.policyUrl;
1097
+ a.rel="nofollow noopener";
1098
+ a.target="_blank";
1099
+ a.appendChild(document.createTextNode(C.ui.whatWeUse));
1100
+ li.appendChild(a);
1101
+ }
1102
+ ul.appendChild(li);
1103
+ });
1104
+ det.appendChild(ul);
1105
+ parent.appendChild(det);
1106
+ }
1107
+ function mkRow(label,id,on,locked){
1108
+ var w=document.createElement("label");
1109
+ w.className="emprivacy-switch";
1110
+ var cb=document.createElement("input");
1111
+ cb.type="checkbox";
1112
+ cb.id=id;
1113
+ cb.checked=on;
1114
+ if(locked){cb.checked=true;cb.disabled=true;}
1115
+ w.appendChild(cb);
1116
+ w.appendChild(document.createTextNode(" "+label));
1117
+ return {wrap:w,box:cb};
1118
+ }
1119
+ var openPanel=function(){};
1120
+ function installApi(){
1121
+ var api=Object.freeze({
1122
+ get:function(){return currentState();},
1123
+ has:function(cat){
1124
+ if(cat==="essential")return true;
1125
+ var st=currentState();
1126
+ if(!st)return false;
1127
+ if(cat==="functional")return !!st.functional;
1128
+ if(cat==="analytics")return !!st.analytics;
1129
+ if(cat==="marketing")return !!st.marketing;
1130
+ return false;
1131
+ },
1132
+ onChange:function(cb){
1133
+ if(typeof cb!=="function")return function(){};
1134
+ listeners.push(cb);
1135
+ return function(){listeners=listeners.filter(function(x){return x!==cb;});};
1136
+ },
1137
+ open:function(){openPanel();}
1138
+ });
1139
+ try{Object.defineProperty(window,"emprivacy",{value:api,writable:false,configurable:false});}catch(e){window.emprivacy=api;}
1140
+ }
1141
+ function applyTheme(root){
1142
+ try{
1143
+ root.style.setProperty("--emprivacy-bg",C.theme.bg);
1144
+ root.style.setProperty("--emprivacy-text",C.theme.text);
1145
+ root.style.setProperty("--emprivacy-accent",C.theme.accent);
1146
+ root.style.setProperty("--emprivacy-radius",String(C.theme.radiusPx)+"px");
1147
+ }catch(e){}
1148
+ }
1149
+ function mount(){
1150
+ var root=document.getElementById("emprivacy-root");
1151
+ if(!root)return;
1152
+ applyTheme(root);
1153
+ var initial=currentState();
1154
+ if(initial) hydrateEmbeds(initial);
1155
+ var trigger=document.createElement("button");
1156
+ trigger.type="button";
1157
+ trigger.className="emprivacy-cookie-trigger";
1158
+ trigger.setAttribute("aria-label",C.ui.cookieSettings);
1159
+ var ic=document.createElement("span");
1160
+ ic.setAttribute("aria-hidden","true");
1161
+ ic.className="emprivacy-cookie-trigger-icon";
1162
+ ic.appendChild(document.createTextNode("🍪"));
1163
+ trigger.appendChild(ic);
1164
+ function showTrigger(){trigger.removeAttribute("hidden");}
1165
+ function hideTrigger(){trigger.setAttribute("hidden","");}
1166
+ function buildPanel(isReopen,hint){
362
1167
  var st=parseState(readCookie());
363
- if(!st)return;
364
- hideTrigger();
365
- var aOn=!!st.a;
366
- var mOn=!!st.m;
1168
+ var aOn=st?!!st.analytics:(C.strictDefaults?false:true);
1169
+ var mOn=st?!!st.marketing:(C.strictDefaults?false:true);
1170
+ var fOn=st?!!st.functional:(C.strictDefaults?false:true);
1171
+ if(isReopen) hideTrigger();
367
1172
  var bar=document.createElement("div");
368
- bar.className="emprivacy-bar emprivacy-bar--reopen";
1173
+ bar.className="emprivacy-bar"+(isReopen?" emprivacy-bar--reopen":"");
369
1174
  bar.setAttribute("role","dialog");
370
- bar.setAttribute("aria-modal","true");
1175
+ bar.setAttribute("aria-modal",isReopen?"true":"false");
371
1176
  var title=document.createElement("h2");
372
1177
  title.className="emprivacy-title";
373
1178
  title.appendChild(document.createTextNode(C.bannerTitle));
@@ -379,132 +1184,399 @@ links.className="emprivacy-links";
379
1184
  addPolicyLinks(links);
380
1185
  var opts=document.createElement("div");
381
1186
  opts.className="emprivacy-opts";
382
- var er=mkRow("Analytics","emprivacy-ra",aOn);
383
- var mr=mkRow("Marketing","emprivacy-rm",mOn);
1187
+ if(!isReopen) opts.setAttribute("hidden","");
1188
+ if(hint){
1189
+ var hintEl=document.createElement("p");
1190
+ hintEl.className="emprivacy-note";
1191
+ hintEl.appendChild(document.createTextNode(hint));
1192
+ opts.appendChild(hintEl);
1193
+ show(opts);
1194
+ }
1195
+ var ess=mkRow(C.ui.essential,isReopen?"emprivacy-re":"emprivacy-e",true,true);
1196
+ var note=document.createElement("p");
1197
+ note.className="emprivacy-note";
1198
+ note.appendChild(document.createTextNode(C.ui.essentialNote));
1199
+ var fr=mkRow(C.ui.functional,isReopen?"emprivacy-rf":"emprivacy-f",fOn,false);
1200
+ var er=mkRow(C.ui.analytics,isReopen?"emprivacy-ra":"emprivacy-a",aOn,false);
1201
+ var mr=mkRow(C.ui.marketing,isReopen?"emprivacy-rm":"emprivacy-m",mOn,false);
1202
+ opts.appendChild(ess.wrap);
1203
+ opts.appendChild(note);
1204
+ opts.appendChild(fr.wrap);
384
1205
  opts.appendChild(er.wrap);
385
1206
  opts.appendChild(mr.wrap);
1207
+ addVendorDisclosure(opts);
386
1208
  var actions=document.createElement("div");
387
1209
  actions.className="emprivacy-actions";
388
- function doReopenPersist(a,m){
389
- writeCookie(JSON.stringify({v:C.policyVersion,a:a?1:0,m:m?1:0}));
390
- logServer(a,m);
391
- location.reload();
392
- }
393
1210
  var btnAll=document.createElement("button");
394
1211
  btnAll.type="button";
395
1212
  btnAll.className="emprivacy-btn emprivacy-btn-primary";
396
- btnAll.appendChild(document.createTextNode("Accept all"));
397
- btnAll.addEventListener("click",function(){doReopenPersist(true,true);});
1213
+ btnAll.appendChild(document.createTextNode(C.ui.acceptAll));
1214
+ btnAll.addEventListener("click",function(){persist(true,true,true);});
398
1215
  var btnRej=document.createElement("button");
399
1216
  btnRej.type="button";
400
1217
  btnRej.className="emprivacy-btn";
401
- btnRej.appendChild(document.createTextNode("Reject non-essential"));
402
- btnRej.addEventListener("click",function(){doReopenPersist(false,false);});
1218
+ btnRej.appendChild(document.createTextNode(C.ui.rejectNonEssential));
1219
+ btnRej.addEventListener("click",function(){persist(false,false,false);});
403
1220
  var btnSave=document.createElement("button");
404
1221
  btnSave.type="button";
405
1222
  btnSave.className="emprivacy-btn emprivacy-btn-primary";
406
- btnSave.appendChild(document.createTextNode("Save choices"));
407
- btnSave.addEventListener("click",function(){doReopenPersist(!!er.box.checked,!!mr.box.checked);});
1223
+ if(!isReopen&&!hint) btnSave.setAttribute("hidden","");
1224
+ btnSave.appendChild(document.createTextNode(C.ui.saveChoices));
1225
+ btnSave.addEventListener("click",function(){persist(!!er.box.checked,!!mr.box.checked,!!fr.box.checked);});
1226
+ actions.appendChild(btnAll);
1227
+ actions.appendChild(btnRej);
1228
+ if(!isReopen){
1229
+ var btnCust=document.createElement("button");
1230
+ btnCust.type="button";
1231
+ btnCust.className="emprivacy-btn";
1232
+ btnCust.appendChild(document.createTextNode(C.ui.customize));
1233
+ btnCust.addEventListener("click",function(){show(opts);btnSave.removeAttribute("hidden");});
1234
+ actions.appendChild(btnCust);
1235
+ }else{
408
1236
  var btnClose=document.createElement("button");
409
1237
  btnClose.type="button";
410
1238
  btnClose.className="emprivacy-btn";
411
- btnClose.appendChild(document.createTextNode("Close"));
1239
+ btnClose.appendChild(document.createTextNode(C.ui.close));
412
1240
  btnClose.addEventListener("click",function(){
413
1241
  if(bar.parentNode)bar.parentNode.removeChild(bar);
414
1242
  showTrigger();
415
1243
  });
416
- actions.appendChild(btnAll);
417
- actions.appendChild(btnRej);
418
- actions.appendChild(btnSave);
419
1244
  actions.appendChild(btnClose);
1245
+ }
1246
+ actions.appendChild(btnSave);
420
1247
  bar.appendChild(title);
421
1248
  bar.appendChild(msg);
422
1249
  bar.appendChild(links);
423
1250
  bar.appendChild(opts);
424
1251
  bar.appendChild(actions);
425
- root.appendChild(bar);
1252
+ return bar;
1253
+ }
1254
+ function openReopenPanel(hint){
1255
+ if(root.querySelector(".emprivacy-bar--reopen"))return;
1256
+ var st=parseState(readCookie());
1257
+ if(!st)return;
1258
+ root.appendChild(buildPanel(true,hint||""));
1259
+ }
1260
+ openPanel=function(hint){
1261
+ var st=parseState(readCookie());
1262
+ if(st) openReopenPanel(hint);
1263
+ else{
1264
+ var existing=root.querySelector(".emprivacy-bar");
1265
+ if(existing){
1266
+ if(hint){
1267
+ var note=document.createElement("p");
1268
+ note.className="emprivacy-note";
1269
+ note.appendChild(document.createTextNode(hint));
1270
+ var opts=existing.querySelector(".emprivacy-opts");
1271
+ if(opts){opts.insertBefore(note,opts.firstChild);show(opts);}
1272
+ }
1273
+ try{existing.scrollIntoView({block:"end"});}catch(e){}
1274
+ }
426
1275
  }
1276
+ };
427
1277
  root.appendChild(trigger);
428
1278
  hideTrigger();
1279
+ function wireEmbedButtons(){
1280
+ document.addEventListener("click",function(ev){
1281
+ var t=ev.target;
1282
+ if(!t||!t.closest)return;
1283
+ var btn=t.closest("[data-emprivacy-embed-load]");
1284
+ if(!btn)return;
1285
+ ev.preventDefault();
1286
+ if(!C.gateEmbeds)return;
1287
+ var st=currentState();
1288
+ if(st&&embedAllowed(st)){
1289
+ hydrateEmbeds(st);
1290
+ return;
1291
+ }
1292
+ openPanel(C.ui.embedNeedConsent);
1293
+ });
1294
+ }
1295
+ wireEmbedButtons();
429
1296
  if(!needBanner()){
430
1297
  var st=parseState(readCookie());
431
- applyScripts(!!(st&&st.a),!!(st&&st.m));
432
- gtagUpdate(!!(st&&st.a),!!(st&&st.m));
1298
+ if(st){
1299
+ applyScripts(st);
1300
+ gtagUpdate(st);
1301
+ hydrateEmbeds(st);
1302
+ }
433
1303
  showTrigger();
434
1304
  trigger.addEventListener("click",function(){openReopenPanel();});
1305
+ installApi();
435
1306
  return;
436
1307
  }
437
- var aOn=C.strictDefaults?false:true;
438
- var mOn=C.strictDefaults?false:true;
439
- var bar=document.createElement("div");
440
- bar.className="emprivacy-bar";
441
- bar.setAttribute("role","dialog");
442
- bar.setAttribute("aria-modal","false");
443
- var title=document.createElement("h2");
444
- title.className="emprivacy-title";
445
- title.appendChild(document.createTextNode(C.bannerTitle));
446
- var msg=document.createElement("p");
447
- msg.className="emprivacy-msg";
448
- msg.appendChild(document.createTextNode(C.bannerMessage));
449
- var links=document.createElement("div");
450
- links.className="emprivacy-links";
451
- addPolicyLinks(links);
452
- var opts=document.createElement("div");
453
- opts.className="emprivacy-opts";
454
- opts.setAttribute("hidden","");
455
- var er=mkRow("Analytics","emprivacy-a",aOn);
456
- var mr=mkRow("Marketing","emprivacy-m",mOn);
457
- opts.appendChild(er.wrap);
458
- opts.appendChild(mr.wrap);
459
- var actions=document.createElement("div");
460
- actions.className="emprivacy-actions";
461
- function persist(a,m){
462
- writeCookie(JSON.stringify({v:C.policyVersion,a:a?1:0,m:m?1:0}));
463
- logServer(a,m);
464
- // For Cloudflare Web Analytics (and most tag-based analytics), injecting after load can miss the initial pageview.
465
- // Reload ensures the script runs in the normal page lifecycle immediately after consent is persisted.
466
- location.reload();
467
- }
468
- var btnAll=document.createElement("button");
469
- btnAll.type="button";
470
- btnAll.className="emprivacy-btn emprivacy-btn-primary";
471
- btnAll.appendChild(document.createTextNode("Accept all"));
472
- btnAll.addEventListener("click",function(){persist(true,true);});
473
- var btnRej=document.createElement("button");
474
- btnRej.type="button";
475
- btnRej.className="emprivacy-btn";
476
- btnRej.appendChild(document.createTextNode("Reject non-essential"));
477
- btnRej.addEventListener("click",function(){persist(false,false);});
478
- var btnCust=document.createElement("button");
479
- btnCust.type="button";
480
- btnCust.className="emprivacy-btn";
481
- btnCust.appendChild(document.createTextNode("Customize"));
482
- btnCust.addEventListener("click",function(){show(opts);btnSave.removeAttribute("hidden");});
483
- var btnSave=document.createElement("button");
484
- btnSave.type="button";
485
- btnSave.className="emprivacy-btn emprivacy-btn-primary";
486
- btnSave.setAttribute("hidden","");
487
- btnSave.appendChild(document.createTextNode("Save choices"));
488
- btnSave.addEventListener("click",function(){persist(!!er.box.checked,!!mr.box.checked);});
489
- actions.appendChild(btnAll);
490
- actions.appendChild(btnRej);
491
- actions.appendChild(btnCust);
492
- actions.appendChild(btnSave);
493
- bar.appendChild(title);
494
- bar.appendChild(msg);
495
- bar.appendChild(links);
496
- bar.appendChild(opts);
497
- bar.appendChild(actions);
498
- root.appendChild(bar);
1308
+ root.appendChild(buildPanel(false,""));
1309
+ trigger.addEventListener("click",function(){openReopenPanel();});
1310
+ installApi();
499
1311
  }
500
1312
  if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",mount);
501
1313
  else mount();
502
1314
  })();`;
503
1315
  }
504
- /**
505
- * Native EmDash runtime — loaded via the named `createPlugin` export.
506
- * Uses `page:fragments`, which requires trusted in-process (native) execution.
507
- */
1316
+ function buildGoogleConsentHeadScript() {
1317
+ return `(function(){window.dataLayer=window.dataLayer||[];function g(){window.dataLayer.push(arguments);}window.gtag=g;g("consent","default",{"analytics_storage":"denied","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied","functionality_storage":"denied","security_storage":"granted","personalization_storage":"denied"});})();`;
1318
+ }
1319
+ //#endregion
1320
+ //#region src/vendors.ts
1321
+ const PLAUSIBLE_SCRIPT = "https://plausible.io/js/script.js";
1322
+ const FATHOM_SCRIPT = "https://cdn.usefathom.com/script.js";
1323
+ const SIMPLE_SCRIPT = "https://scripts.simpleanalyticscdn.com/latest.js";
1324
+ function buildAnalyticsLoader(cfg) {
1325
+ switch (cfg.analyticsProvider) {
1326
+ case "none": return { type: "none" };
1327
+ case "cloudflare": return cfg.cloudflareWebAnalyticsToken ? {
1328
+ type: "cloudflare",
1329
+ token: cfg.cloudflareWebAnalyticsToken
1330
+ } : { type: "none" };
1331
+ case "custom": return cfg.analyticsScriptUrls.length ? {
1332
+ type: "custom",
1333
+ scripts: cfg.analyticsScriptUrls.map((src) => ({
1334
+ src,
1335
+ integrity: cfg.scriptIntegrity[src] ?? null
1336
+ }))
1337
+ } : { type: "none" };
1338
+ case "plausible": return isHostname$1(cfg.analyticsId) ? {
1339
+ type: "plausible",
1340
+ src: PLAUSIBLE_SCRIPT,
1341
+ domain: cfg.analyticsId.trim().toLowerCase()
1342
+ } : { type: "none" };
1343
+ case "fathom": return isFathomId(cfg.analyticsId) ? {
1344
+ type: "fathom",
1345
+ src: FATHOM_SCRIPT,
1346
+ siteId: cfg.analyticsId.trim()
1347
+ } : { type: "none" };
1348
+ case "umami": return isUmamiId(cfg.analyticsId) && cfg.umamiScriptUrl ? {
1349
+ type: "umami",
1350
+ src: cfg.umamiScriptUrl,
1351
+ websiteId: cfg.analyticsId.trim()
1352
+ } : { type: "none" };
1353
+ case "simpleanalytics": return {
1354
+ type: "simpleanalytics",
1355
+ src: SIMPLE_SCRIPT
1356
+ };
1357
+ case "ga4": return isGa4Id(cfg.analyticsId) ? {
1358
+ type: "ga4",
1359
+ measurementId: cfg.analyticsId.trim()
1360
+ } : { type: "none" };
1361
+ case "gtm": return isGtmId(cfg.analyticsId) ? {
1362
+ type: "gtm",
1363
+ containerId: cfg.analyticsId.trim()
1364
+ } : { type: "none" };
1365
+ default: return { type: "none" };
1366
+ }
1367
+ }
1368
+ function buildVendorList(cfg) {
1369
+ const rows = [{
1370
+ id: "emprivacy",
1371
+ name: "EmPrivacy consent cookie",
1372
+ category: "essential",
1373
+ purpose: "Stores your category choices (`emprivacy_cc`) so the banner does not reappear every visit."
1374
+ }];
1375
+ if (cfg.gateEmbeds) rows.push({
1376
+ id: "embeds",
1377
+ name: "Third-party embeds (YouTube, Vimeo, social posts, Gists)",
1378
+ category: cfg.embedCategory,
1379
+ purpose: "Loads official EmDash embed blocks only after this category is allowed. Unknown or unsafe embed URLs are never framed."
1380
+ });
1381
+ const loader = buildAnalyticsLoader(cfg);
1382
+ if (loader.type === "cloudflare") rows.push({
1383
+ id: "cloudflare-wa",
1384
+ name: "Cloudflare Web Analytics",
1385
+ category: "analytics",
1386
+ purpose: "Privacy-oriented page analytics via Cloudflare’s beacon after Analytics consent.",
1387
+ policyUrl: "https://www.cloudflare.com/privacypolicy/"
1388
+ });
1389
+ else if (loader.type === "plausible") rows.push({
1390
+ id: "plausible",
1391
+ name: "Plausible Analytics",
1392
+ category: "analytics",
1393
+ purpose: "Cookieless first-party analytics loaded after Analytics consent.",
1394
+ policyUrl: "https://plausible.io/privacy"
1395
+ });
1396
+ else if (loader.type === "fathom") rows.push({
1397
+ id: "fathom",
1398
+ name: "Fathom Analytics",
1399
+ category: "analytics",
1400
+ purpose: "Privacy-focused analytics loaded after Analytics consent.",
1401
+ policyUrl: "https://usefathom.com/privacy"
1402
+ });
1403
+ else if (loader.type === "umami") rows.push({
1404
+ id: "umami",
1405
+ name: "Umami",
1406
+ category: "analytics",
1407
+ purpose: "Self-hosted or Umami Cloud analytics loaded after Analytics consent.",
1408
+ policyUrl: "https://umami.is/privacy"
1409
+ });
1410
+ else if (loader.type === "simpleanalytics") rows.push({
1411
+ id: "simpleanalytics",
1412
+ name: "Simple Analytics",
1413
+ category: "analytics",
1414
+ purpose: "Privacy-focused analytics loaded after Analytics consent.",
1415
+ policyUrl: "https://www.simpleanalytics.com/privacy-policy"
1416
+ });
1417
+ else if (loader.type === "ga4") rows.push({
1418
+ id: "ga4",
1419
+ name: "Google Analytics 4",
1420
+ category: "analytics",
1421
+ purpose: "Google Analytics loaded after Analytics consent. Enable Google Consent Mode if you also use Google tags.",
1422
+ policyUrl: "https://policies.google.com/privacy"
1423
+ });
1424
+ else if (loader.type === "gtm") rows.push({
1425
+ id: "gtm",
1426
+ name: "Google Tag Manager",
1427
+ category: "marketing",
1428
+ purpose: "Loads GTM only after Marketing consent (containers often fire ads/remarketing). You remain responsible for tags inside the container.",
1429
+ policyUrl: "https://policies.google.com/privacy"
1430
+ });
1431
+ else if (loader.type === "custom") for (const [i, script] of loader.scripts.entries()) {
1432
+ let host = "custom script";
1433
+ try {
1434
+ host = new URL(script.src).hostname;
1435
+ } catch {}
1436
+ rows.push({
1437
+ id: `custom-analytics-${i}`,
1438
+ name: `Custom analytics (${host})`,
1439
+ category: "analytics",
1440
+ purpose: "A script URL you configured. Loaded only after Analytics consent."
1441
+ });
1442
+ }
1443
+ for (const [i, src] of cfg.marketingScriptUrls.entries()) {
1444
+ let host = "custom script";
1445
+ try {
1446
+ host = new URL(src).hostname;
1447
+ } catch {}
1448
+ rows.push({
1449
+ id: `marketing-${i}`,
1450
+ name: `Marketing script (${host})`,
1451
+ category: "marketing",
1452
+ purpose: "A marketing or advertising script you configured. Loaded only after Marketing consent."
1453
+ });
1454
+ }
1455
+ if (cfg.googleConsentMode) rows.push({
1456
+ id: "gcm",
1457
+ name: "Google Consent Mode v2",
1458
+ category: "essential",
1459
+ purpose: "Sets denied-by-default Google consent signals in the page; updates after the visitor chooses.",
1460
+ policyUrl: "https://support.google.com/tagmanager/answer/13695607"
1461
+ });
1462
+ return rows;
1463
+ }
1464
+ //#endregion
1465
+ //#region src/version.ts
1466
+ const VERSION = "3.0.0";
1467
+ //#endregion
1468
+ //#region src/runtime.ts
1469
+ /** Soft cap for anonymous consent POSTs per client fingerprint per UTC hour. */
1470
+ const RECORD_RATE_LIMIT = 30;
1471
+ const RECORD_LOG_CAP = 500;
1472
+ const ADMIN_SETTINGS_PATH = "/settings";
1473
+ const SAVE_ACTION_ID = "emprivacy-save";
1474
+ const recordInput = z.object({
1475
+ policyVersion: z.string().trim().min(1).max(64),
1476
+ functional: z.boolean(),
1477
+ analytics: z.boolean(),
1478
+ marketing: z.boolean()
1479
+ });
1480
+ async function loadConfig(ctx) {
1481
+ const raw = await ctx.kv.get(KV_KEY);
1482
+ if (!raw) return normalizeConfig({});
1483
+ try {
1484
+ return normalizeConfig(JSON.parse(raw));
1485
+ } catch {
1486
+ return normalizeConfig({});
1487
+ }
1488
+ }
1489
+ async function saveConfigString(ctx, json) {
1490
+ await ctx.kv.set(KV_KEY, json);
1491
+ }
1492
+ function recordPathForSite() {
1493
+ return `/_emdash/api/plugins/${PLUGIN_ID}/record`;
1494
+ }
1495
+ function absolutePolicyHref(stored, ctx) {
1496
+ const r = resolvePolicyHref(stored, ctx).trim();
1497
+ if (!r) return null;
1498
+ try {
1499
+ if (new URL(r).protocol !== "https:") return null;
1500
+ return r;
1501
+ } catch {
1502
+ return null;
1503
+ }
1504
+ }
1505
+ function clientFingerKey(request) {
1506
+ const cf = request.headers.get("cf-connecting-ip")?.trim();
1507
+ const xff = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
1508
+ const ua = (request.headers.get("user-agent") ?? "").slice(0, 120);
1509
+ return `${cf || xff || "unknown"}|${ua}`;
1510
+ }
1511
+ async function hashFinger(s) {
1512
+ const data = new TextEncoder().encode(`emprivacy-rl:${s}`);
1513
+ const dig = await crypto.subtle.digest("SHA-256", data);
1514
+ return Array.from(new Uint8Array(dig)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
1515
+ }
1516
+ async function allowRecordWrite(ctx, request) {
1517
+ const key = `consent:rl:${rateHourKey()}:${await hashFinger(clientFingerKey(request))}`;
1518
+ try {
1519
+ const raw = await ctx.kv.get(key);
1520
+ const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0;
1521
+ if (n >= RECORD_RATE_LIMIT) return false;
1522
+ await ctx.kv.set(key, String(n + 1));
1523
+ return true;
1524
+ } catch {
1525
+ return false;
1526
+ }
1527
+ }
1528
+ function publicRuntime(cfg, ctx, page) {
1529
+ const copy = resolvePublicCopy(cfg, page.locale);
1530
+ const hideBanner = cfg.hideBannerOnPolicyPages && isPolicyPagePath(page.path, cfg);
1531
+ return {
1532
+ bannerTitle: copy.bannerTitle,
1533
+ bannerMessage: copy.bannerMessage,
1534
+ privacyPolicyUrl: absolutePolicyHref(cfg.privacyPolicyUrl, ctx) ?? "",
1535
+ cookiePolicyUrl: cfg.cookiePolicyUrl ? absolutePolicyHref(cfg.cookiePolicyUrl, ctx) ?? "" : "",
1536
+ strictDefaults: cfg.strictDefaults,
1537
+ policyVersion: cfg.policyVersion,
1538
+ googleConsentMode: cfg.googleConsentMode,
1539
+ logConsent: cfg.logConsentToServer,
1540
+ recordPath: recordPathForSite(),
1541
+ embedCategory: cfg.embedCategory,
1542
+ gateEmbeds: cfg.gateEmbeds,
1543
+ hideBanner,
1544
+ theme: cfg.theme,
1545
+ ui: copy.ui,
1546
+ loader: buildAnalyticsLoader(cfg),
1547
+ marketingScripts: cfg.marketingScriptUrls.map((src) => ({
1548
+ src,
1549
+ integrity: cfg.scriptIntegrity[src] ?? null
1550
+ })),
1551
+ scriptHostAllowlist: cfg.scriptHostAllowlist,
1552
+ scriptIntegrity: cfg.scriptIntegrity,
1553
+ cookieMaxAge: cookieMaxAgeSeconds(cfg.cookieMaxAgeDays),
1554
+ vendors: buildVendorList(cfg)
1555
+ };
1556
+ }
1557
+ function themeStyle() {
1558
+ return `<style id="emprivacy-style">
1559
+ #emprivacy-root{font-family:system-ui,sans-serif;font-size:14px;--emprivacy-bg:#111111;--emprivacy-text:#eeeeee;--emprivacy-accent:#3b82f6;--emprivacy-radius:6px}
1560
+ .emprivacy-bar{position:fixed;z-index:99999;left:0;right:0;bottom:0;background:var(--emprivacy-bg);color:var(--emprivacy-text);padding:16px 20px 20px;box-shadow:0 -4px 24px rgba(0,0,0,.25);max-height:45vh;overflow:auto}
1561
+ .emprivacy-title{margin:0 0 8px;font-size:1.1rem}
1562
+ .emprivacy-msg,.emprivacy-note{margin:0 0 10px;line-height:1.4;opacity:.95}
1563
+ .emprivacy-note{font-size:.85rem}
1564
+ .emprivacy-links a{color:var(--emprivacy-accent);margin-right:12px}
1565
+ .emprivacy-opts{margin:10px 0;border-top:1px solid color-mix(in srgb,var(--emprivacy-text) 20%,transparent);padding-top:10px}
1566
+ .emprivacy-switch{display:block;margin:6px 0}
1567
+ .emprivacy-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
1568
+ .emprivacy-btn{border-radius:var(--emprivacy-radius);border:1px solid color-mix(in srgb,var(--emprivacy-text) 35%,transparent);background:transparent;color:var(--emprivacy-text);padding:8px 12px;cursor:pointer}
1569
+ .emprivacy-btn-primary{background:var(--emprivacy-accent);border-color:var(--emprivacy-accent);color:#fff}
1570
+ .emprivacy-cookie-trigger{position:fixed;z-index:99998;left:16px;bottom:16px;width:48px;height:48px;border-radius:50%;border:1px solid color-mix(in srgb,var(--emprivacy-text) 35%,transparent);background:var(--emprivacy-bg);color:var(--emprivacy-text);box-shadow:0 2px 12px rgba(0,0,0,.2);cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
1571
+ .emprivacy-cookie-trigger:hover{filter:brightness(1.1)}
1572
+ .emprivacy-cookie-trigger:focus{outline:2px solid var(--emprivacy-accent);outline-offset:2px}
1573
+ .emprivacy-cookie-trigger[hidden]{display:none!important}
1574
+ .emprivacy-cookie-trigger-icon{font-size:1.35rem;line-height:1}
1575
+ .emprivacy-bar--reopen{z-index:100000}
1576
+ .emprivacy-vendors{margin:10px 0 0}
1577
+ .emprivacy-vendors ul{margin:8px 0 0;padding-left:1.2rem}
1578
+ </style>`;
1579
+ }
508
1580
  function createPlugin() {
509
1581
  return definePlugin({
510
1582
  id: PLUGIN_ID,
@@ -518,7 +1590,7 @@ function createPlugin() {
518
1590
  }] },
519
1591
  hooks: {
520
1592
  "plugin:install": async (_e, ctx) => {
521
- if (!await ctx.kv.get(KV_KEY)) {
1593
+ if (!await ctx.kv.get("consent:config")) {
522
1594
  await saveConfigString(ctx, JSON.stringify(normalizeConfig({})));
523
1595
  ctx.log.info("EmPrivacy: seeded default configuration");
524
1596
  }
@@ -542,47 +1614,14 @@ function createPlugin() {
542
1614
  });
543
1615
  return out.length ? out : null;
544
1616
  } },
545
- "page:fragments": { handler: async (_e, ctx) => {
1617
+ "page:fragments": { handler: async (e, ctx) => {
546
1618
  const cfg = await loadConfig(ctx);
547
- const privacyResolved = absolutePolicyHref(cfg.privacyPolicyUrl, ctx) ?? "";
548
- const cookieResolved = cfg.cookiePolicyUrl ? absolutePolicyHref(cfg.cookiePolicyUrl, ctx) ?? "" : "";
549
- const pr = {
550
- bannerTitle: cfg.bannerTitle,
551
- bannerMessage: cfg.bannerMessage,
552
- privacyPolicyUrl: privacyResolved,
553
- cookiePolicyUrl: cookieResolved,
554
- strictDefaults: cfg.strictDefaults,
555
- policyVersion: cfg.policyVersion,
556
- analyticsProvider: cfg.analyticsProvider,
557
- cloudflareToken: cfg.cloudflareWebAnalyticsToken,
558
- analyticsScriptUrls: cfg.analyticsScriptUrls,
559
- marketingScriptUrls: cfg.marketingScriptUrls,
560
- googleConsentMode: cfg.googleConsentMode,
561
- logConsent: cfg.logConsentToServer,
562
- recordPath: recordPathForSite()
563
- };
1619
+ const pr = publicRuntime(cfg, ctx, e.page);
564
1620
  const style = {
565
1621
  kind: "html",
566
1622
  placement: "body:end",
567
1623
  key: "emprivacy:style",
568
- html: `<style id="emprivacy-style">
569
- #emprivacy-root{font-family:system-ui,sans-serif;font-size:14px}
570
- .emprivacy-bar{position:fixed;z-index:99999;left:0;right:0;bottom:0;background:#111;color:#eee;padding:16px 20px 20px;box-shadow:0 -4px 24px rgba(0,0,0,.25);max-height:45vh;overflow:auto}
571
- .emprivacy-title{margin:0 0 8px;font-size:1.1rem}
572
- .emprivacy-msg{margin:0 0 10px;line-height:1.4;opacity:.95}
573
- .emprivacy-links a{color:#8ecbff;margin-right:12px}
574
- .emprivacy-opts{margin:10px 0;border-top:1px solid #333;padding-top:10px}
575
- .emprivacy-switch{display:block;margin:6px 0}
576
- .emprivacy-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
577
- .emprivacy-btn{border-radius:6px;border:1px solid #555;background:#222;color:#eee;padding:8px 12px;cursor:pointer}
578
- .emprivacy-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff}
579
- .emprivacy-cookie-trigger{position:fixed;z-index:99998;left:16px;bottom:16px;width:48px;height:48px;border-radius:50%;border:1px solid #555;background:#222;color:#e8b86d;box-shadow:0 2px 12px rgba(0,0,0,.2);cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
580
- .emprivacy-cookie-trigger:hover{background:#2a2a2a}
581
- .emprivacy-cookie-trigger:focus{outline:2px solid #3b82f6;outline-offset:2px}
582
- .emprivacy-cookie-trigger[hidden]{display:none!important}
583
- .emprivacy-cookie-trigger-icon{font-size:1.35rem;line-height:1}
584
- .emprivacy-bar--reopen{z-index:100000}
585
- </style>`
1624
+ html: themeStyle()
586
1625
  };
587
1626
  const shell = {
588
1627
  kind: "html",
@@ -633,10 +1672,23 @@ function createPlugin() {
633
1672
  policyVersion: String(v.policy_version ?? ""),
634
1673
  analyticsPlatform: String(v.analytics_platform ?? "cloudflare"),
635
1674
  cloudflareToken: String(v.cloudflare_token ?? ""),
1675
+ analyticsId: String(v.analytics_id ?? ""),
1676
+ umamiScriptUrl: String(v.umami_script_url ?? ""),
636
1677
  analyticsUrlsText: String(v.analytics_urls ?? ""),
637
1678
  marketingUrlsText: String(v.marketing_urls ?? ""),
1679
+ scriptHostAllowlistText: String(v.script_host_allowlist ?? ""),
1680
+ cookieMaxAgeDays: String(v.cookie_max_age_days ?? "180"),
638
1681
  googleConsentMode: asBool(v.google_cm),
639
- logConsentToServer: asBool(v.log_server)
1682
+ logConsentToServer: asBool(v.log_server),
1683
+ embedCategory: String(v.embed_category ?? "marketing"),
1684
+ gateEmbeds: asBool(v.gate_embeds),
1685
+ hideBannerOnPolicyPages: asBool(v.hide_on_policy),
1686
+ defaultLocale: String(v.default_locale ?? "en"),
1687
+ localeOverridesText: String(v.locale_overrides ?? ""),
1688
+ themeBg: String(v.theme_bg ?? ""),
1689
+ themeText: String(v.theme_text ?? ""),
1690
+ themeAccent: String(v.theme_accent ?? ""),
1691
+ themeRadius: String(v.theme_radius ?? "")
640
1692
  });
641
1693
  await saveConfigString(ctx, JSON.stringify(next));
642
1694
  return {
@@ -663,15 +1715,15 @@ function createPlugin() {
663
1715
  public: true,
664
1716
  input: recordInput,
665
1717
  handler: async (ctx) => {
666
- if (ctx.request.method !== "POST") return { ok: false };
1718
+ if (ctx.request.method !== "POST") return new Response("method_not_allowed", { status: 405 });
1719
+ let expectedOrigin;
667
1720
  try {
668
- const origin = ctx.request.headers.get("origin");
669
- if (origin) {
670
- if (origin !== new URL(ctx.url("/")).origin) return new Response("forbidden", { status: 403 });
671
- }
1721
+ expectedOrigin = new URL(ctx.url("/")).origin;
672
1722
  } catch {
673
1723
  return new Response("bad_request", { status: 400 });
674
1724
  }
1725
+ const csrf = assertSameOriginMutation(ctx.request, expectedOrigin);
1726
+ if (csrf) return csrf;
675
1727
  if (!(ctx.request.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) return new Response("unsupported_media_type", { status: 415 });
676
1728
  const cfg = await loadConfig(ctx);
677
1729
  if (!cfg.logConsentToServer) return {
@@ -680,34 +1732,60 @@ function createPlugin() {
680
1732
  };
681
1733
  const input = ctx.input;
682
1734
  if (input.policyVersion !== cfg.policyVersion) return new Response("policy_version_mismatch", { status: 400 });
1735
+ if (!await allowRecordWrite(ctx, ctx.request)) return new Response("rate_limited", { status: 429 });
683
1736
  const row = {
684
1737
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
685
1738
  policyVersion: input.policyVersion,
1739
+ functional: input.functional,
686
1740
  analytics: input.analytics,
687
1741
  marketing: input.marketing
688
1742
  };
689
1743
  try {
690
1744
  if ((await ctx.storage.consentEvents.query({
691
1745
  orderBy: { createdAt: "desc" },
692
- limit: 500
693
- })).items.length >= 500) return {
1746
+ limit: RECORD_LOG_CAP
1747
+ })).items.length >= RECORD_LOG_CAP) return {
694
1748
  ok: false,
695
1749
  reason: "log_full"
696
1750
  };
697
- } catch {}
1751
+ } catch {
1752
+ return {
1753
+ ok: false,
1754
+ reason: "storage_unavailable"
1755
+ };
1756
+ }
698
1757
  await ctx.storage.consentEvents.put(`${Date.now()}-${Math.random().toString(36).slice(2)}`, row);
699
1758
  return { ok: true };
700
1759
  }
1760
+ },
1761
+ vendors: {
1762
+ public: true,
1763
+ handler: async (ctx) => {
1764
+ if (ctx.request.method !== "GET") return new Response("method_not_allowed", { status: 405 });
1765
+ return { vendors: buildVendorList(await loadConfig(ctx)) };
1766
+ }
701
1767
  }
702
1768
  }
703
1769
  });
704
1770
  }
705
1771
  async function buildSettingsPage(ctx) {
706
- const cfg = await loadConfig(ctx);
707
- const analyticsText = cfg.analyticsScriptUrls.join("\n");
708
- const marketingText = cfg.marketingScriptUrls.join("\n");
1772
+ const [cfg, latestPublished] = await Promise.all([loadConfig(ctx), fetchLatestPublishedVersion({ fetch: ctx.http?.fetch ?? globalThis.fetch })]);
1773
+ const installedVersion = ctx.plugin?.version ?? "3.0.0";
1774
+ const analyticsText = cfg.analyticsScriptUrls.map((src) => {
1775
+ const i = cfg.scriptIntegrity[src];
1776
+ return i ? `${src} ${i}` : src;
1777
+ }).join("\n");
1778
+ const marketingText = cfg.marketingScriptUrls.map((src) => {
1779
+ const i = cfg.scriptIntegrity[src];
1780
+ return i ? `${src} ${i}` : src;
1781
+ }).join("\n");
709
1782
  const platform = cfg.analyticsProvider;
710
1783
  const docsUrl = "https://github.com/EmPlugins/EmPrivacy/blob/main/docs/PLUGIN_SETTINGS.md";
1784
+ const vendorFields = buildVendorList(cfg).map((v) => ({
1785
+ label: `${v.name} · ${v.category}`,
1786
+ value: v.purpose
1787
+ }));
1788
+ const localeOverridesText = Object.keys(cfg.localeOverrides).length > 0 ? JSON.stringify(cfg.localeOverrides, null, 2) : "";
711
1789
  const consentFields = await (async () => {
712
1790
  try {
713
1791
  return (await ctx.storage.consentEvents.query({
@@ -715,9 +1793,10 @@ async function buildSettingsPage(ctx) {
715
1793
  limit: 15
716
1794
  })).items.map((item) => {
717
1795
  const d = item.data;
1796
+ const functional = typeof d.functional === "boolean" ? ` · functional ${d.functional ? "on" : "off"}` : "";
718
1797
  return {
719
1798
  label: d.createdAt,
720
- value: `policy ${d.policyVersion} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}`
1799
+ value: `policy ${d.policyVersion}${functional} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}`
721
1800
  };
722
1801
  });
723
1802
  } catch {
@@ -729,17 +1808,25 @@ async function buildSettingsPage(ctx) {
729
1808
  type: "header",
730
1809
  text: "EmPrivacy — Cookie & consent"
731
1810
  },
1811
+ {
1812
+ type: "fields",
1813
+ fields: pluginVersionFields(installedVersion, latestPublished)
1814
+ },
1815
+ {
1816
+ type: "context",
1817
+ text: pluginVersionNote(installedVersion, latestPublished)
1818
+ },
732
1819
  {
733
1820
  type: "context",
734
1821
  text: `Documentation: ${docsUrl}`
735
1822
  },
736
1823
  {
737
1824
  type: "context",
738
- text: "Configure the public banner. **Analytics:** pick a platform (Cloudflare is built in — enter your **site token** from Cloudflare → Web Analytics; EmPrivacy injects the standard beacon with `data-cf-beacon` after consent). **None** = no third-party analytics scripts. **Custom** = one `https://` script URL per line, `src` only. **Marketing:** `https` script URLs, one per line, after marketing consent. No arbitrary admin HTML in the public site."
1825
+ text: "This plugin is a technical consent layer, not legal advice and not a privacy-policy generator. You still write and publish the legal pages. EmPrivacy does not scan your theme for leftover trackers, does not geo-locate visitors, and does not block first-party EmDash comments."
739
1826
  },
740
1827
  {
741
1828
  type: "context",
742
- text: "Privacy / cookie links: use your EmDash **Page** public path (e.g. `/privacy` — same as in the browser address bar when you view that Page) or a full `https://…` URL if the policy is hosted elsewhere."
1829
+ text: "Privacy / cookie links: use your EmDash **Page** public path (e.g. `/privacy`) or a full `https://…` URL. Banner chrome follows the page locale when a built-in translation exists (en, de, fr, es, it, nl, pt, pl)."
743
1830
  },
744
1831
  { type: "divider" },
745
1832
  {
@@ -749,16 +1836,31 @@ async function buildSettingsPage(ctx) {
749
1836
  {
750
1837
  type: "text_input",
751
1838
  action_id: "banner_title",
752
- label: "Banner title",
1839
+ label: "Banner title (fallback locale)",
753
1840
  initial_value: cfg.bannerTitle
754
1841
  },
755
1842
  {
756
1843
  type: "text_input",
757
1844
  action_id: "banner_message",
758
- label: "Short notice",
1845
+ label: "Short notice (fallback locale)",
759
1846
  multiline: true,
760
1847
  initial_value: cfg.bannerMessage
761
1848
  },
1849
+ {
1850
+ type: "text_input",
1851
+ action_id: "default_locale",
1852
+ label: "Fallback locale code",
1853
+ placeholder: "en",
1854
+ initial_value: cfg.defaultLocale
1855
+ },
1856
+ {
1857
+ type: "text_input",
1858
+ action_id: "locale_overrides",
1859
+ label: "Optional title/message translations (JSON)",
1860
+ placeholder: "{\"de\":{\"bannerTitle\":\"Cookies & Datenschutz\",\"bannerMessage\":\"…\"}}\n",
1861
+ multiline: true,
1862
+ initial_value: localeOverridesText
1863
+ },
762
1864
  {
763
1865
  type: "text_input",
764
1866
  action_id: "privacy_url",
@@ -782,9 +1884,15 @@ async function buildSettingsPage(ctx) {
782
1884
  {
783
1885
  type: "toggle",
784
1886
  action_id: "strict_defaults",
785
- label: "Strict defaults (require opt-in for analytics & marketing)",
1887
+ label: "Strict defaults (require opt-in for functional, analytics, and marketing)",
786
1888
  initial_value: cfg.strictDefaults
787
1889
  },
1890
+ {
1891
+ type: "toggle",
1892
+ action_id: "hide_on_policy",
1893
+ label: "Hide the first-visit banner on privacy and cookie policy pages",
1894
+ initial_value: cfg.hideBannerOnPolicyPages
1895
+ },
788
1896
  {
789
1897
  type: "radio",
790
1898
  action_id: "analytics_platform",
@@ -794,6 +1902,30 @@ async function buildSettingsPage(ctx) {
794
1902
  value: "cloudflare",
795
1903
  label: "Cloudflare Web Analytics"
796
1904
  },
1905
+ {
1906
+ value: "plausible",
1907
+ label: "Plausible"
1908
+ },
1909
+ {
1910
+ value: "fathom",
1911
+ label: "Fathom"
1912
+ },
1913
+ {
1914
+ value: "umami",
1915
+ label: "Umami"
1916
+ },
1917
+ {
1918
+ value: "simpleanalytics",
1919
+ label: "Simple Analytics"
1920
+ },
1921
+ {
1922
+ value: "ga4",
1923
+ label: "Google Analytics 4"
1924
+ },
1925
+ {
1926
+ value: "gtm",
1927
+ label: "Google Tag Manager (requires Marketing consent)"
1928
+ },
797
1929
  {
798
1930
  value: "none",
799
1931
  label: "None"
@@ -816,11 +1948,29 @@ async function buildSettingsPage(ctx) {
816
1948
  eq: "cloudflare"
817
1949
  }
818
1950
  },
1951
+ {
1952
+ type: "text_input",
1953
+ action_id: "analytics_id",
1954
+ label: "Analytics ID (domain, site ID, G-…, or GTM-…)",
1955
+ placeholder: "example.com / G-XXXX / GTM-XXXX",
1956
+ initial_value: cfg.analyticsId
1957
+ },
1958
+ {
1959
+ type: "text_input",
1960
+ action_id: "umami_script_url",
1961
+ label: "Umami script URL (https only)",
1962
+ placeholder: "https://cloud.umami.is/script.js",
1963
+ initial_value: cfg.umamiScriptUrl,
1964
+ condition: {
1965
+ field: "analytics_platform",
1966
+ eq: "umami"
1967
+ }
1968
+ },
819
1969
  {
820
1970
  type: "text_input",
821
1971
  action_id: "analytics_urls",
822
- label: "Custom analytics script URLs (one https URL per line, not a <script> tag)",
823
- placeholder: "https://… (one per line)",
1972
+ label: "Custom analytics script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
1973
+ placeholder: "https://cdn.example/a.js sha384-…",
824
1974
  multiline: true,
825
1975
  initial_value: analyticsText,
826
1976
  condition: {
@@ -831,21 +1981,83 @@ async function buildSettingsPage(ctx) {
831
1981
  {
832
1982
  type: "text_input",
833
1983
  action_id: "marketing_urls",
834
- label: "Marketing script URLs (one https URL per line, not a <script> tag)",
1984
+ label: "Marketing script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
835
1985
  placeholder: "https://… (one per line)",
836
1986
  multiline: true,
837
1987
  initial_value: marketingText
838
1988
  },
1989
+ {
1990
+ type: "text_input",
1991
+ action_id: "script_host_allowlist",
1992
+ label: "Script host allowlist (optional; one hostname per line). When set, Custom analytics and marketing URLs must match.",
1993
+ placeholder: "cdn.example.com\njs.stripe.com",
1994
+ multiline: true,
1995
+ initial_value: cfg.scriptHostAllowlist.join("\n")
1996
+ },
1997
+ {
1998
+ type: "text_input",
1999
+ action_id: "cookie_max_age_days",
2000
+ label: "Consent cookie lifetime (days, 1–365, default 180)",
2001
+ placeholder: "180",
2002
+ initial_value: String(cfg.cookieMaxAgeDays)
2003
+ },
839
2004
  {
840
2005
  type: "toggle",
841
2006
  action_id: "google_cm",
842
2007
  label: "Google Consent Mode v2 (denied defaults in head; updates after choice)",
843
2008
  initial_value: cfg.googleConsentMode
844
2009
  },
2010
+ {
2011
+ type: "toggle",
2012
+ action_id: "gate_embeds",
2013
+ label: "Gate official EmDash embed blocks (YouTube, Vimeo, social, Gist)",
2014
+ initial_value: cfg.gateEmbeds
2015
+ },
2016
+ {
2017
+ type: "radio",
2018
+ action_id: "embed_category",
2019
+ label: "Embeds require this category",
2020
+ options: [{
2021
+ value: "marketing",
2022
+ label: "Marketing (recommended for YouTube / social)"
2023
+ }, {
2024
+ value: "functional",
2025
+ label: "Functional"
2026
+ }],
2027
+ initial_value: cfg.embedCategory
2028
+ },
2029
+ {
2030
+ type: "text_input",
2031
+ action_id: "theme_bg",
2032
+ label: "Banner background (hex)",
2033
+ placeholder: "#111111",
2034
+ initial_value: cfg.theme.bg
2035
+ },
2036
+ {
2037
+ type: "text_input",
2038
+ action_id: "theme_text",
2039
+ label: "Banner text (hex)",
2040
+ placeholder: "#eeeeee",
2041
+ initial_value: cfg.theme.text
2042
+ },
2043
+ {
2044
+ type: "text_input",
2045
+ action_id: "theme_accent",
2046
+ label: "Banner accent (hex)",
2047
+ placeholder: "#3b82f6",
2048
+ initial_value: cfg.theme.accent
2049
+ },
2050
+ {
2051
+ type: "text_input",
2052
+ action_id: "theme_radius",
2053
+ label: "Banner corner radius (0–24 px)",
2054
+ placeholder: "6",
2055
+ initial_value: String(cfg.theme.radiusPx)
2056
+ },
845
2057
  {
846
2058
  type: "toggle",
847
2059
  action_id: "log_server",
848
- label: "Log consent choices to the server (minimal record; no IP stored)",
2060
+ label: "Log consent choices to the server (minimal record; no IP; rate-limited; Origin/Sec-Fetch-Site required)",
849
2061
  initial_value: cfg.logConsentToServer
850
2062
  }
851
2063
  ],
@@ -854,6 +2066,19 @@ async function buildSettingsPage(ctx) {
854
2066
  action_id: SAVE_ACTION_ID
855
2067
  }
856
2068
  },
2069
+ { type: "divider" },
2070
+ {
2071
+ type: "header",
2072
+ text: "What this site uses"
2073
+ },
2074
+ {
2075
+ type: "context",
2076
+ text: "Generated from your current settings. Paste this into your cookie policy if you want a living vendor list. Also available at `/_emdash/api/plugins/emprivacy/vendors`. Tokens and script IDs are not included."
2077
+ },
2078
+ {
2079
+ type: "fields",
2080
+ fields: vendorFields
2081
+ },
857
2082
  ...cfg.logConsentToServer && consentFields.length > 0 ? [
858
2083
  { type: "divider" },
859
2084
  {
@@ -867,16 +2092,247 @@ async function buildSettingsPage(ctx) {
867
2092
  ] : []
868
2093
  ] };
869
2094
  }
870
-
2095
+ //#endregion
2096
+ //#region src/astro/embed-resolve.ts
2097
+ /**
2098
+ * Validate official EmDash embed block payloads and produce a first-party
2099
+ * placeholder plan. Never returns a URL we did not construct or allowlist.
2100
+ */
2101
+ const EMBED_BLOCK_TYPES = [
2102
+ "youtube",
2103
+ "vimeo",
2104
+ "tweet",
2105
+ "bluesky",
2106
+ "mastodon",
2107
+ "linkPreview",
2108
+ "gist"
2109
+ ];
2110
+ const YT_ID = /^[A-Za-z0-9_-]{11}$/;
2111
+ const VIMEO_ID = /^\d{6,12}$/;
2112
+ const TWEET_ID = /^\d{5,20}$/;
2113
+ const BSKY_RKEY = /^[a-z0-9]{1,32}$/i;
2114
+ const BSKY_HANDLE = /^[a-z0-9.-]{1,253}$/i;
2115
+ const GIST_ID = /^[a-f0-9]{8,64}$/i;
2116
+ const GIST_USER = /^[A-Za-z0-9-]{1,39}$/;
2117
+ const YT_HOSTS = /* @__PURE__ */ new Set([
2118
+ "youtube.com",
2119
+ "www.youtube.com",
2120
+ "m.youtube.com",
2121
+ "youtu.be",
2122
+ "www.youtube-nocookie.com",
2123
+ "youtube-nocookie.com"
2124
+ ]);
2125
+ const VIMEO_HOSTS = /* @__PURE__ */ new Set([
2126
+ "vimeo.com",
2127
+ "www.vimeo.com",
2128
+ "player.vimeo.com"
2129
+ ]);
2130
+ const TWEET_HOSTS = /* @__PURE__ */ new Set([
2131
+ "twitter.com",
2132
+ "www.twitter.com",
2133
+ "x.com",
2134
+ "www.x.com",
2135
+ "mobile.twitter.com"
2136
+ ]);
2137
+ const BSKY_HOSTS = /* @__PURE__ */ new Set(["bsky.app", "www.bsky.app"]);
2138
+ const GIST_HOSTS = /* @__PURE__ */ new Set(["gist.github.com"]);
2139
+ const POSTER_HOSTS = /* @__PURE__ */ new Set([
2140
+ "i.ytimg.com",
2141
+ "img.youtube.com",
2142
+ "i.vimeocdn.com"
2143
+ ]);
2144
+ function hasUnsafeChars(s) {
2145
+ return /[\u0000-\u001F\u007F\s]/.test(s);
2146
+ }
2147
+ function parseHttpsUrl(raw) {
2148
+ const t = raw.trim();
2149
+ if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
2150
+ if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
2151
+ try {
2152
+ const u = new URL(t);
2153
+ if (u.protocol !== "https:") return null;
2154
+ if (u.username || u.password) return null;
2155
+ if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
2156
+ return u;
2157
+ } catch {
2158
+ return null;
2159
+ }
2160
+ }
2161
+ function hostOf(u) {
2162
+ return u.hostname.toLowerCase();
2163
+ }
2164
+ function youtubeId(raw) {
2165
+ const t = raw.trim();
2166
+ if (YT_ID.test(t)) return t;
2167
+ const u = parseHttpsUrl(t);
2168
+ if (!u || !YT_HOSTS.has(hostOf(u))) return null;
2169
+ if (hostOf(u) === "youtu.be") {
2170
+ const id = u.pathname.replace(/^\//, "").split("/")[0] ?? "";
2171
+ return YT_ID.test(id) ? id : null;
2172
+ }
2173
+ const v = u.searchParams.get("v");
2174
+ if (v && YT_ID.test(v)) return v;
2175
+ const parts = u.pathname.split("/").filter(Boolean);
2176
+ const embedIdx = parts.indexOf("embed");
2177
+ if (embedIdx >= 0) {
2178
+ const id = parts[embedIdx + 1] ?? "";
2179
+ return YT_ID.test(id) ? id : null;
2180
+ }
2181
+ const shortsIdx = parts.indexOf("shorts");
2182
+ if (shortsIdx >= 0) {
2183
+ const id = parts[shortsIdx + 1] ?? "";
2184
+ return YT_ID.test(id) ? id : null;
2185
+ }
2186
+ return null;
2187
+ }
2188
+ function vimeoId(raw) {
2189
+ const t = raw.trim();
2190
+ if (VIMEO_ID.test(t)) return t;
2191
+ const u = parseHttpsUrl(t);
2192
+ if (!u || !VIMEO_HOSTS.has(hostOf(u))) return null;
2193
+ const parts = u.pathname.split("/").filter(Boolean);
2194
+ const last = parts[parts.length - 1] ?? "";
2195
+ const videoIdx = parts.indexOf("video");
2196
+ if (videoIdx >= 0) {
2197
+ const id = parts[videoIdx + 1] ?? "";
2198
+ return VIMEO_ID.test(id) ? id : null;
2199
+ }
2200
+ return VIMEO_ID.test(last) ? last : null;
2201
+ }
2202
+ function tweetId(raw) {
2203
+ const t = raw.trim();
2204
+ if (TWEET_ID.test(t)) return t;
2205
+ const u = parseHttpsUrl(t);
2206
+ if (!u || !TWEET_HOSTS.has(hostOf(u))) return null;
2207
+ return /\/status(?:es)?\/(\d{5,20})(?:\/|$)/i.exec(u.pathname)?.[1] ?? null;
2208
+ }
2209
+ function blueskyHref(raw) {
2210
+ const u = parseHttpsUrl(raw);
2211
+ if (!u || !BSKY_HOSTS.has(hostOf(u))) return null;
2212
+ const m = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/.exec(u.pathname);
2213
+ if (!m) return null;
2214
+ const handle = m[1] ?? "";
2215
+ const rkey = m[2] ?? "";
2216
+ if (!BSKY_HANDLE.test(handle) || !BSKY_RKEY.test(rkey)) return null;
2217
+ return `https://bsky.app/profile/${handle}/post/${rkey}`;
2218
+ }
2219
+ function gistHref(raw) {
2220
+ const u = parseHttpsUrl(raw);
2221
+ if (!u || !GIST_HOSTS.has(hostOf(u))) return null;
2222
+ const parts = u.pathname.split("/").filter(Boolean);
2223
+ if (parts.length < 2) return null;
2224
+ const user = parts[0] ?? "";
2225
+ const id = (parts[1] ?? "").replace(/\.js$/i, "");
2226
+ if (!GIST_USER.test(user) || !GIST_ID.test(id)) return null;
2227
+ return `https://gist.github.com/${user}/${id}`;
2228
+ }
2229
+ function mastodonHref(raw) {
2230
+ const u = parseHttpsUrl(raw);
2231
+ if (!u) return null;
2232
+ if (!/^\/@[^/]+\/\d+\/?$/.test(u.pathname) && !/^\/users\/[^/]+\/statuses\/\d+\/?$/.test(u.pathname)) return null;
2233
+ return `https://${hostOf(u)}${u.pathname.replace(/\/$/, "")}`;
2234
+ }
2235
+ function posterUrl(raw, kind, mediaId) {
2236
+ if (kind === "youtube" && mediaId && YT_ID.test(mediaId)) return `https://i.ytimg.com/vi/${mediaId}/hqdefault.jpg`;
2237
+ if (typeof raw !== "string") return null;
2238
+ const u = parseHttpsUrl(raw);
2239
+ if (!u || !POSTER_HOSTS.has(hostOf(u))) return null;
2240
+ return u.href;
2241
+ }
2242
+ function isEmbedBlockType(s) {
2243
+ return EMBED_BLOCK_TYPES.includes(s);
2244
+ }
2245
+ function resolveEmbed(node) {
2246
+ if (!node || typeof node._type !== "string" || !isEmbedBlockType(node._type)) return null;
2247
+ if (typeof node.id !== "string") return null;
2248
+ const kind = node._type;
2249
+ const rawId = node.id.trim();
2250
+ if (!rawId || rawId.length > 2048) return null;
2251
+ if (kind === "youtube") {
2252
+ const id = youtubeId(rawId);
2253
+ if (!id) return null;
2254
+ return {
2255
+ kind,
2256
+ mode: "iframe",
2257
+ src: `https://www.youtube-nocookie.com/embed/${id}`,
2258
+ label: "YouTube",
2259
+ poster: posterUrl(node.poster, kind, id)
2260
+ };
2261
+ }
2262
+ if (kind === "vimeo") {
2263
+ const id = vimeoId(rawId);
2264
+ if (!id) return null;
2265
+ return {
2266
+ kind,
2267
+ mode: "iframe",
2268
+ src: `https://player.vimeo.com/video/${id}`,
2269
+ label: "Vimeo",
2270
+ poster: posterUrl(node.poster, kind, id)
2271
+ };
2272
+ }
2273
+ if (kind === "tweet") {
2274
+ const id = tweetId(rawId);
2275
+ if (!id) return null;
2276
+ return {
2277
+ kind,
2278
+ mode: "link",
2279
+ src: `https://x.com/i/web/status/${id}`,
2280
+ label: "Post on X",
2281
+ poster: null
2282
+ };
2283
+ }
2284
+ if (kind === "bluesky") {
2285
+ const href = blueskyHref(rawId);
2286
+ if (!href) return null;
2287
+ return {
2288
+ kind,
2289
+ mode: "link",
2290
+ src: href,
2291
+ label: "Bluesky post",
2292
+ poster: null
2293
+ };
2294
+ }
2295
+ if (kind === "mastodon") {
2296
+ const href = mastodonHref(rawId);
2297
+ if (!href) return null;
2298
+ return {
2299
+ kind,
2300
+ mode: "link",
2301
+ src: href,
2302
+ label: "Mastodon post",
2303
+ poster: null
2304
+ };
2305
+ }
2306
+ if (kind === "gist") {
2307
+ const href = gistHref(rawId);
2308
+ if (!href) return null;
2309
+ return {
2310
+ kind,
2311
+ mode: "link",
2312
+ src: href,
2313
+ label: "GitHub Gist",
2314
+ poster: null
2315
+ };
2316
+ }
2317
+ const href = parseHttpsUrl(rawId);
2318
+ if (!href) return null;
2319
+ return {
2320
+ kind: "linkPreview",
2321
+ mode: "link",
2322
+ src: href.href,
2323
+ label: href.hostname,
2324
+ poster: null
2325
+ };
2326
+ }
871
2327
  //#endregion
872
2328
  //#region src/index.ts
873
2329
  var src_default = createPlugin;
874
2330
  /**
875
2331
  * EmDash native plugin descriptor — add to `plugins: []` in `emdash({ ... })` inside `astro.config`.
876
2332
  *
877
- * EmPrivacy uses `page:fragments` (banner + consent scripts), which only runs for **native**
878
- * plugins registered in `plugins: []`. Do not place this descriptor in `sandboxed: []`.
879
- * See [Page fragments](https://docs.emdashcms.com/plugins/creating-native-plugins/page-fragments/).
2333
+ * EmPrivacy uses `page:fragments` (banner + consent scripts) and optional Portable Text
2334
+ * embed placeholders, which only run for **native** plugins in `plugins: []`.
2335
+ * Do not place this descriptor in `sandboxed: []`.
880
2336
  */
881
2337
  function emprivacyPlugin() {
882
2338
  return {
@@ -884,6 +2340,7 @@ function emprivacyPlugin() {
884
2340
  version: VERSION,
885
2341
  format: "native",
886
2342
  entrypoint: "@emplugins/emprivacy",
2343
+ componentsEntry: "@emplugins/emprivacy/astro",
887
2344
  adminPages: [{
888
2345
  path: "/settings",
889
2346
  label: "EmPrivacy",
@@ -891,6 +2348,5 @@ function emprivacyPlugin() {
891
2348
  }]
892
2349
  };
893
2350
  }
894
-
895
2351
  //#endregion
896
- export { COOKIE_NAME, DEFAULT_CONFIG, KV_KEY, assertValidCloudflareToken, createPlugin, src_default as default, emprivacyPlugin, isRootRelativeSitePath, isValidPolicyHrefInput, normalizeConfig, resolvePolicyHref };
2352
+ export { COOKIE_NAME, DEFAULT_CONFIG, EMBED_BLOCK_TYPES, KV_KEY, assertValidCloudflareToken, buildAnalyticsLoader, buildVendorList, createPlugin, src_default as default, emprivacyPlugin, isPolicyPagePath, isRootRelativeSitePath, isValidPolicyHrefInput, normalizeConfig, resolveEmbed, resolvePolicyHref };