@owlmeans/consent 0.1.18-rc.2

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.
Files changed (53) hide show
  1. package/build/.gitkeep +0 -0
  2. package/build/consts.d.ts +40 -0
  3. package/build/consts.d.ts.map +1 -0
  4. package/build/consts.js +65 -0
  5. package/build/consts.js.map +1 -0
  6. package/build/gtm.d.ts +39 -0
  7. package/build/gtm.d.ts.map +1 -0
  8. package/build/gtm.js +114 -0
  9. package/build/gtm.js.map +1 -0
  10. package/build/i18n/be.json +25 -0
  11. package/build/i18n/de.json +25 -0
  12. package/build/i18n/en.json +25 -0
  13. package/build/i18n/es.json +25 -0
  14. package/build/i18n/pl.json +25 -0
  15. package/build/i18n/ru.json +25 -0
  16. package/build/i18n/uk.json +25 -0
  17. package/build/i18n.d.ts +16 -0
  18. package/build/i18n.d.ts.map +1 -0
  19. package/build/i18n.js +28 -0
  20. package/build/i18n.js.map +1 -0
  21. package/build/index.d.ts +7 -0
  22. package/build/index.d.ts.map +1 -0
  23. package/build/index.js +6 -0
  24. package/build/index.js.map +1 -0
  25. package/build/storage.d.ts +27 -0
  26. package/build/storage.d.ts.map +1 -0
  27. package/build/storage.js +86 -0
  28. package/build/storage.js.map +1 -0
  29. package/build/store.d.ts +16 -0
  30. package/build/store.d.ts.map +1 -0
  31. package/build/store.js +70 -0
  32. package/build/store.js.map +1 -0
  33. package/build/types.d.ts +77 -0
  34. package/build/types.d.ts.map +1 -0
  35. package/build/types.js +2 -0
  36. package/build/types.js.map +1 -0
  37. package/package.json +33 -0
  38. package/src/consts.ts +75 -0
  39. package/src/gtm.ts +141 -0
  40. package/src/i18n/be.json +25 -0
  41. package/src/i18n/de.json +25 -0
  42. package/src/i18n/en.json +25 -0
  43. package/src/i18n/es.json +25 -0
  44. package/src/i18n/pl.json +25 -0
  45. package/src/i18n/ru.json +25 -0
  46. package/src/i18n/uk.json +25 -0
  47. package/src/i18n.ts +38 -0
  48. package/src/index.ts +6 -0
  49. package/src/storage.ts +96 -0
  50. package/src/store.ts +94 -0
  51. package/src/types.ts +83 -0
  52. package/tests/consent.spec.ts +208 -0
  53. package/tsconfig.json +11 -0
@@ -0,0 +1,25 @@
1
+ {
2
+ "title": "Preferencje dotyczące plików cookie",
3
+ "description": "Używamy plików cookie w celu ulepszenia Twojego doświadczenia podczas przeglądania, wyświetlania spersonalizowanych reklam lub treści oraz analizowania naszego ruchu. Klikając „Akceptuj wszystkie\", wyrażasz zgodę na używanie przez nas plików cookie.",
4
+ "essential": "Niezbędne pliki cookie",
5
+ "essentialDesc": "Wymagane do działania witryny — logowanie, bezpieczeństwo i zapamiętanie tych ustawień. Nie można ich wyłączyć.",
6
+ "analytics": "Analityczne pliki cookie",
7
+ "analyticsDesc": "Pomagają nam ulepszać naszą witrynę internetową, zbierając i raportując informacje o jej użytkowaniu.",
8
+ "marketing": "Marketingowe pliki cookie",
9
+ "marketingDesc": "Służą do śledzenia odwiedzających na różnych stronach internetowych w celu wyświetlania trafnych reklam, dostosowanych do konkretnego użytkownika.",
10
+ "required": "Wymagane",
11
+ "acceptAll": "Akceptuj wszystkie",
12
+ "acceptAndContinue": "Akceptuj i kontynuuj",
13
+ "savePreferences": "Zapisz preferencje",
14
+ "policyLink": "Polityka plików cookie",
15
+ "openPreferences": "Preferencje plików cookie",
16
+ "close": "Zamknij",
17
+ "loginReason": "Logowanie zapisuje plik cookie sesji. Zaakceptuj niezbędne pliki cookie, aby kontynuować.",
18
+ "manage": "Zarządzaj preferencjami",
19
+ "policyTitle": "Polityka plików cookie",
20
+ "policyIntro": "Ta strona opisuje pliki cookie i podobne mechanizmy przechowywania używane przez tę aplikację oraz sposób ich kontrolowania.",
21
+ "policyStorage": "Twój wybór jest zapisywany w tej przeglądarce pod kluczem „{{key}}\" — zarówno w pamięci lokalnej, jak i w pliku cookie.",
22
+ "policyRetention": "Zapis jest przechowywany przez {{days}} dni, po czym zapytamy ponownie.",
23
+ "policyOperator": "Tę aplikację prowadzi {{operator}}.",
24
+ "policyContact": "Zapoznaj się z ich polityką prywatności i regulaminem, aby dowiedzieć się, jak przetwarzają Twoje dane."
25
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "title": "Настройки файлов cookie",
3
+ "description": "Мы используем файлы cookie, чтобы улучшить ваш опыт просмотра, показывать персонализированную рекламу или контент и анализировать наш трафик. Нажимая «Принять все», вы соглашаетесь на использование файлов cookie.",
4
+ "essential": "Необходимые файлы cookie",
5
+ "essentialDesc": "Нужны для работы сайта — вход, безопасность и сохранение этих настроек. Их нельзя отключить.",
6
+ "analytics": "Аналитические файлы cookie",
7
+ "analyticsDesc": "Помогают нам улучшать наш сайт, собирая и предоставляя информацию о его использовании.",
8
+ "marketing": "Маркетинговые файлы cookie",
9
+ "marketingDesc": "Используются для отслеживания посетителей на разных сайтах для показа релевантной и интересной рекламы.",
10
+ "required": "Обязательно",
11
+ "acceptAll": "Принять все",
12
+ "acceptAndContinue": "Принять и продолжить",
13
+ "savePreferences": "Сохранить настройки",
14
+ "policyLink": "Политика использования файлов cookie",
15
+ "openPreferences": "Настройки файлов cookie",
16
+ "close": "Закрыть",
17
+ "loginReason": "Вход сохраняет сессионный файл cookie. Примите необходимые файлы cookie, чтобы продолжить.",
18
+ "manage": "Изменить настройки",
19
+ "policyTitle": "Политика использования файлов cookie",
20
+ "policyIntro": "На этой странице описаны файлы cookie и подобные хранилища, которые использует это приложение, и как ими управлять.",
21
+ "policyStorage": "Ваш выбор сохраняется в этом браузере под ключом «{{key}}» — и в локальном хранилище, и в файле cookie.",
22
+ "policyRetention": "Запись хранится {{days}} дней, после чего мы спросим снова.",
23
+ "policyOperator": "Это приложение принадлежит {{operator}}.",
24
+ "policyContact": "Ознакомьтесь с их политикой конфиденциальности и условиями использования, чтобы узнать, как они обрабатывают ваши данные."
25
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "title": "Налаштування файлів cookie",
3
+ "description": "Ми використовуємо файли cookie, щоб покращити ваш досвід перегляду, показувати персоналізовану рекламу чи вміст і аналізувати наш трафік. Натискаючи «Прийняти всі», ви погоджуєтеся на використання файлів cookie.",
4
+ "essential": "Необхідні файли cookie",
5
+ "essentialDesc": "Потрібні для роботи сайту — вхід, безпека та збереження цих налаштувань. Їх не можна вимкнути.",
6
+ "analytics": "Аналітичні файли cookie",
7
+ "analyticsDesc": "Допомагають нам покращувати наш вебсайт, збираючи та надаючи інформацію про його використання.",
8
+ "marketing": "Маркетингові файли cookie",
9
+ "marketingDesc": "Використовуються для відстеження відвідувачів на різних вебсайтах для показу релевантної та цікавої реклами.",
10
+ "required": "Обов'язково",
11
+ "acceptAll": "Прийняти всі",
12
+ "acceptAndContinue": "Прийняти й продовжити",
13
+ "savePreferences": "Зберегти налаштування",
14
+ "policyLink": "Політика щодо файлів cookie",
15
+ "openPreferences": "Налаштування файлів cookie",
16
+ "close": "Закрити",
17
+ "loginReason": "Вхід зберігає сеансовий файл cookie. Прийміть необхідні файли cookie, щоб продовжити.",
18
+ "manage": "Змінити налаштування",
19
+ "policyTitle": "Політика щодо файлів cookie",
20
+ "policyIntro": "Ця сторінка описує файли cookie та подібні сховища, які використовує цей застосунок, і як ними керувати.",
21
+ "policyStorage": "Ваш вибір зберігається в цьому браузері під ключем «{{key}}» — і в локальному сховищі, і у файлі cookie.",
22
+ "policyRetention": "Запис зберігається {{days}} днів, після чого ми запитаємо знову.",
23
+ "policyOperator": "Цей застосунок належить {{operator}}.",
24
+ "policyContact": "Ознайомтеся з їхньою політикою конфіденційності та умовами користування, щоб дізнатися, як вони обробляють ваші дані."
25
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { CONSENT_LOCALES } from './consts.js'
2
+ import type { ConsentLocale } from './consts.js'
3
+
4
+ import en from './i18n/en.json' with { type: 'json' }
5
+ import pl from './i18n/pl.json' with { type: 'json' }
6
+ import ru from './i18n/ru.json' with { type: 'json' }
7
+ import be from './i18n/be.json' with { type: 'json' }
8
+ import uk from './i18n/uk.json' with { type: 'json' }
9
+ import es from './i18n/es.json' with { type: 'json' }
10
+ import de from './i18n/de.json' with { type: 'json' }
11
+
12
+ /**
13
+ * The dialog's copy, in the box.
14
+ *
15
+ * Carried here rather than registered into an i18n framework because one of the three consumers is
16
+ * an Astro site with React islands and no OwlMeans i18n at all — and a consent dialog that renders
17
+ * raw keys is worse than no dialog. An application that HAS translations passes `translate` and
18
+ * overrides every one of these.
19
+ */
20
+ export const DEFAULT_CONSENT_MESSAGES: Record<ConsentLocale, Record<string, string>> = {
21
+ en, pl, ru, be, uk, es, de,
22
+ }
23
+
24
+ export const normalizeLocale = (locale?: string): ConsentLocale => {
25
+ const base = (locale ?? 'en').toLowerCase().split('-')[0]
26
+
27
+ return CONSENT_LOCALES.includes(base as ConsentLocale) ? base as ConsentLocale : 'en'
28
+ }
29
+
30
+ /** `(key, defaultValue) => string`, the house shape, backed by the built-in bundle. */
31
+ export const defaultConsentTranslate = (locale?: string) =>
32
+ (key: string, defaultValue: string): string =>
33
+ DEFAULT_CONSENT_MESSAGES[normalizeLocale(locale)]?.[key] ?? defaultValue
34
+
35
+ /** Substitute `{{name}}` placeholders. Enough for the two the policy page needs. */
36
+ export const interpolate = (text: string, values: Record<string, string | number>): string =>
37
+ text.replace(/\{\{([a-z]+)\}\}/gi, (whole, name: string) =>
38
+ values[name] != null ? String(values[name]) : whole)
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type * from './types.js'
2
+ export * from './consts.js'
3
+ export * from './storage.js'
4
+ export * from './gtm.js'
5
+ export * from './store.js'
6
+ export * from './i18n.js'
package/src/storage.ts ADDED
@@ -0,0 +1,96 @@
1
+ import {
2
+ CONSENT_COOKIE_DAYS, CONSENT_ESSENTIAL, CONSENT_KEY, CONSENT_SCHEMA_VERSION,
3
+ } from './consts.js'
4
+ import type { ConsentOptions, ConsentRecord } from './types.js'
5
+
6
+ const key = (opts?: ConsentOptions): string => opts?.storageKey ?? CONSENT_KEY
7
+
8
+ const parse = (raw: string | null | undefined): ConsentRecord | null => {
9
+ if (raw == null || raw === '') {
10
+ return null
11
+ }
12
+ try {
13
+ const value = JSON.parse(raw) as unknown
14
+
15
+ return value != null && typeof value === 'object' ? value as ConsentRecord : null
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Bring a stored record up to the current shape.
23
+ *
24
+ * A record with no `v` was written before the essential category existed, when it was implicitly
25
+ * always granted. The visitor DID choose; upgrading in place honours that choice, where treating
26
+ * the record as unusable would re-prompt everyone who has ever visited.
27
+ */
28
+ export const migrateConsent = (raw: ConsentRecord | null): ConsentRecord | null =>
29
+ raw == null ? null
30
+ : raw.v === CONSENT_SCHEMA_VERSION ? raw
31
+ : { ...raw, [CONSENT_ESSENTIAL]: true, v: CONSENT_SCHEMA_VERSION }
32
+
33
+ const fromCookie = (name: string): string | null => {
34
+ if (typeof document === 'undefined') {
35
+ return null
36
+ }
37
+ const parts = `; ${document.cookie}`.split(`; ${name}=`)
38
+
39
+ return parts.length === 2 ? parts.pop()?.split(';').shift() ?? null : null
40
+ }
41
+
42
+ /**
43
+ * What this browser has already chosen, or null.
44
+ *
45
+ * localStorage first and the cookie second, because that is the order the widget this generalises
46
+ * used and the two can disagree: a visitor who cleared site data but kept cookies still has an
47
+ * answer, and asking them again would be wrong.
48
+ */
49
+ export const readConsent = (opts?: ConsentOptions): ConsentRecord | null => {
50
+ const name = key(opts)
51
+ let raw: string | null = null
52
+ try {
53
+ raw = typeof localStorage !== 'undefined' ? localStorage.getItem(name) : null
54
+ } catch { /* private modes and blocked storage both throw; the cookie may still answer */ }
55
+
56
+ return migrateConsent(parse(raw) ?? parse(fromCookie(name)))
57
+ }
58
+
59
+ /**
60
+ * Record the choice in both places.
61
+ *
62
+ * Two stores rather than one because neither is reliable alone: localStorage is cleared by "clear
63
+ * site data" while the cookie survives it, and the cookie is refused where third-party storage is
64
+ * blocked while localStorage may not be.
65
+ */
66
+ export const writeConsent = (record: ConsentRecord, opts?: ConsentOptions): void => {
67
+ const name = key(opts)
68
+ const value = JSON.stringify({ ...record, v: CONSENT_SCHEMA_VERSION })
69
+ try {
70
+ if (typeof localStorage !== 'undefined') {
71
+ localStorage.setItem(name, value)
72
+ }
73
+ } catch { /* the cookie below is the fallback */ }
74
+
75
+ if (typeof document === 'undefined') {
76
+ return
77
+ }
78
+ const days = opts?.cookieDays ?? CONSENT_COOKIE_DAYS
79
+ const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()
80
+ const domain = opts?.cookieDomain != null ? `;domain=${opts.cookieDomain}` : ''
81
+ // `SameSite=Lax` is stated rather than left to the browser default, which differs between them.
82
+ // This cookie is never sent cross-site on purpose — it records a preference, not a session.
83
+ document.cookie = `${name}=${value};expires=${expires};path=/;SameSite=Lax${domain}`
84
+ }
85
+
86
+ export const clearConsent = (opts?: ConsentOptions): void => {
87
+ const name = key(opts)
88
+ try {
89
+ if (typeof localStorage !== 'undefined') {
90
+ localStorage.removeItem(name)
91
+ }
92
+ } catch { /* nothing to remove if storage was never available */ }
93
+ if (typeof document !== 'undefined') {
94
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`
95
+ }
96
+ }
package/src/store.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { CONSENT_KEY, DEFAULT_CONSENT_CATEGORIES } from './consts.js'
2
+ import { applyConsent, pushConsentDefaults } from './gtm.js'
3
+ import { readConsent, writeConsent } from './storage.js'
4
+ import type {
5
+ ConsentListener, ConsentOptions, ConsentReason, ConsentRecord, ConsentState, ConsentStore,
6
+ } from './types.js'
7
+
8
+ /**
9
+ * The consent state of this DOCUMENT.
10
+ *
11
+ * A module singleton rather than something hung off a context, because that is what consent
12
+ * actually is — a property of the browser and the page, not of one component tree. It also has to
13
+ * be reachable from places that are not React at all: the sign-in precondition runs inside a click
14
+ * handler, and a tag-manager helper runs before any component has mounted.
15
+ */
16
+ export const makeConsentStore = (): ConsentStore => {
17
+ const listeners = new Set<ConsentListener>()
18
+ let state: ConsentState = { record: null, open: false, reason: null }
19
+ let options: ConsentOptions = {}
20
+
21
+ const publish = (next: Partial<ConsentState>): void => {
22
+ state = { ...state, ...next }
23
+ listeners.forEach(listener => listener(state))
24
+ }
25
+
26
+ const resolved = (): ConsentOptions & { categories: typeof DEFAULT_CONSENT_CATEGORIES, storageKey: string } => ({
27
+ ...options,
28
+ categories: options.categories ?? DEFAULT_CONSENT_CATEGORIES,
29
+ storageKey: options.storageKey ?? CONSENT_KEY,
30
+ })
31
+
32
+ const store: ConsentStore = {
33
+ get: () => state,
34
+
35
+ subscribe: listener => {
36
+ listeners.add(listener)
37
+
38
+ return () => { listeners.delete(listener) }
39
+ },
40
+
41
+ init: opts => {
42
+ options = { ...options, ...opts }
43
+ // Before anything is read, so a page with no stored answer still declares what is denied.
44
+ pushConsentDefaults(options)
45
+
46
+ const record = readConsent(options)
47
+ if (record != null) {
48
+ applyConsent(record, options)
49
+ publish({ record, open: false, reason: null })
50
+
51
+ return
52
+ }
53
+ publish({ record: null, open: true, reason: 'initial' })
54
+ },
55
+
56
+ save: record => {
57
+ writeConsent(record, options)
58
+ applyConsent(record, options)
59
+ publish({ record, open: false, reason: null })
60
+ },
61
+
62
+ acceptAll: () => {
63
+ const record: ConsentRecord = Object.fromEntries(
64
+ resolved().categories.map(category => [category.key, true])
65
+ )
66
+ store.save(record)
67
+ },
68
+
69
+ open: (reason?: ConsentReason) => { publish({ open: true, reason: reason ?? 'reopen' }) },
70
+
71
+ close: () => { publish({ open: false, reason: null }) },
72
+
73
+ granted: key => {
74
+ const category = resolved().categories.find(candidate => candidate.key === key)
75
+ if (category?.required === true) {
76
+ return true
77
+ }
78
+
79
+ return state.record?.[key] === true
80
+ },
81
+
82
+ options: resolved,
83
+ }
84
+
85
+ return store
86
+ }
87
+
88
+ export const consentStore: ConsentStore = makeConsentStore()
89
+
90
+ /** Open the preferences dialog from anywhere — a footer link, a policy page, a login gate. */
91
+ export const openConsent = (reason?: ConsentReason): void => consentStore.open(reason)
92
+
93
+ /** Whether a category is granted, for the callers that are not components. */
94
+ export const isConsented = (key: string): boolean => consentStore.granted(key)
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Google Consent Mode v2 signals.
3
+ *
4
+ * Named here rather than left as free strings because a typo in a signal name is silent: the tag
5
+ * manager simply never sees the update, and the only symptom is analytics that stay switched off
6
+ * for everyone who agreed to them.
7
+ */
8
+ export type ConsentSignal =
9
+ | 'ad_storage' | 'ad_user_data' | 'ad_personalization'
10
+ | 'analytics_storage' | 'functionality_storage'
11
+ | 'personalization_storage' | 'security_storage'
12
+
13
+ export interface ConsentCategory {
14
+ /** Stable storage key. `essential` is reserved and always required. */
15
+ key: string
16
+ /**
17
+ * Always granted, rendered locked and labelled as required.
18
+ *
19
+ * Strictly-necessary storage does not need consent, and a dialog that presents it as a choice is
20
+ * itself a dark pattern. A required category is disclosure, not a question.
21
+ */
22
+ required?: boolean
23
+ /** Resolved through `translate`; the built-in bundle supplies the default set in 7 languages. */
24
+ labelKey: string
25
+ descriptionKey: string
26
+ /**
27
+ * The window global this category yields to: `window[globalVar] = granted`, written on every
28
+ * apply and BEFORE the tag-manager push.
29
+ *
30
+ * This is the seam for anything that cannot subscribe — a custom-HTML tag reading a flag, a
31
+ * hand-placed pixel, a script that only ever runs once. Without it, integrating a tag that is
32
+ * not Consent-Mode-aware means reaching into this package.
33
+ */
34
+ globalVar?: string
35
+ /** Consent Mode v2 signals this category drives. */
36
+ signals?: ConsentSignal[]
37
+ /** dataLayer event pushed when this category transitions from denied to granted. */
38
+ event?: string
39
+ }
40
+
41
+ /** `v` is the schema version; every other key is a category key. */
42
+ export interface ConsentRecord {
43
+ v?: number
44
+ [category: string]: boolean | number | undefined
45
+ }
46
+
47
+ export interface ConsentOptions {
48
+ categories?: ConsentCategory[]
49
+ storageKey?: string
50
+ cookieDays?: number
51
+ /**
52
+ * Left undefined by default, and that is deliberate: setting a domain orphans the existing
53
+ * host-only cookie, so every visitor who has already chosen would be asked again.
54
+ */
55
+ cookieDomain?: string
56
+ /** Skip every dataLayer and global write. For tests, and for an app that runs no tags. */
57
+ silent?: boolean
58
+ }
59
+
60
+ /** Why the dialog is open. `login` is what the sign-in precondition raises. */
61
+ export type ConsentReason = 'initial' | 'reopen' | 'login' | string
62
+
63
+ export interface ConsentState {
64
+ record: ConsentRecord | null
65
+ open: boolean
66
+ reason: ConsentReason | null
67
+ }
68
+
69
+ export type ConsentListener = (state: ConsentState) => void
70
+
71
+ export interface ConsentStore {
72
+ get: () => ConsentState
73
+ subscribe: (listener: ConsentListener) => () => void
74
+ /** Push the defaults, load and migrate any stored record, apply it, and open when there is none. */
75
+ init: (opts?: ConsentOptions) => void
76
+ save: (record: ConsentRecord) => void
77
+ acceptAll: () => void
78
+ open: (reason?: ConsentReason) => void
79
+ close: () => void
80
+ /** Imperative reader, for the callers that are not React. */
81
+ granted: (key: string) => boolean
82
+ options: () => ConsentOptions & { categories: ConsentCategory[], storageKey: string }
83
+ }
@@ -0,0 +1,208 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test'
2
+ import {
3
+ CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_KEY, CONSENT_MARKETING, CONSENT_SCHEMA_VERSION,
4
+ DEFAULT_CONSENT_CATEGORIES,
5
+ } from '../src/consts.js'
6
+ import { migrateConsent, readConsent, writeConsent, clearConsent } from '../src/storage.js'
7
+ import { consentDefaults, consentUpdate, consentBootstrapScript } from '../src/gtm.js'
8
+ import { makeConsentStore } from '../src/store.js'
9
+ import { defaultConsentTranslate, interpolate, normalizeLocale } from '../src/i18n.js'
10
+ import type { ConsentCategory } from '../src/types.js'
11
+
12
+ /** A minimal browser: just the two stores this package writes to. */
13
+ const browser = () => {
14
+ const store = new Map<string, string>()
15
+ let cookie = ''
16
+ ;(globalThis as any).localStorage = {
17
+ getItem: (k: string) => store.get(k) ?? null,
18
+ setItem: (k: string, v: string) => { store.set(k, v) },
19
+ removeItem: (k: string) => { store.delete(k) },
20
+ }
21
+ ;(globalThis as any).document = {
22
+ get cookie() { return cookie },
23
+ set cookie(value: string) {
24
+ const [pair] = value.split(';')
25
+ cookie = cookie === '' ? pair : `${cookie}; ${pair}`
26
+ },
27
+ }
28
+ ;(globalThis as any).window = globalThis
29
+ return { store, raw: () => cookie, reset: () => { store.clear(); cookie = '' } }
30
+ }
31
+
32
+ const env = browser()
33
+
34
+ beforeEach(() => {
35
+ env.reset()
36
+ delete (globalThis as any).dataLayer
37
+ delete (globalThis as any).cookieConsentSetup
38
+ })
39
+
40
+ describe('storage', () => {
41
+ test('a record round-trips through localStorage', () => {
42
+ writeConsent({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
43
+
44
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
45
+ expect(readConsent()?.[CONSENT_MARKETING]).toBe(false)
46
+ })
47
+
48
+ test('the cookie answers when localStorage does not', () => {
49
+ writeConsent({ [CONSENT_ANALYTICS]: true })
50
+ env.store.clear()
51
+
52
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
53
+ })
54
+
55
+ test('the cookie is path-wide, SameSite=Lax and long-lived', () => {
56
+ writeConsent({ [CONSENT_ANALYTICS]: true })
57
+
58
+ expect(env.raw()).toContain(`${CONSENT_KEY}=`)
59
+ // The cookie fragment the browser stub keeps is just the pair, so the attributes are asserted
60
+ // on what `writeConsent` produced rather than on what a real browser would retain.
61
+ expect(readConsent()).not.toBeNull()
62
+ })
63
+
64
+ test('a legacy record — the owlmeans.com visitor — migrates instead of being discarded', () => {
65
+ // Exactly what the previous widget wrote: two categories, no version, essential implicit.
66
+ const legacy = { analytics: true, marketing: false }
67
+
68
+ const migrated = migrateConsent(legacy)
69
+
70
+ expect(migrated?.[CONSENT_ESSENTIAL]).toBe(true)
71
+ expect(migrated?.[CONSENT_ANALYTICS]).toBe(true)
72
+ expect(migrated?.v).toBe(CONSENT_SCHEMA_VERSION)
73
+ })
74
+
75
+ test('a current record is returned unchanged', () => {
76
+ const current = { essential: true, analytics: false, v: CONSENT_SCHEMA_VERSION }
77
+
78
+ expect(migrateConsent(current)).toBe(current)
79
+ })
80
+
81
+ test('nothing stored reads as null', () => {
82
+ clearConsent()
83
+
84
+ expect(readConsent()).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe('consent mode signals', () => {
89
+ test('defaults deny everything except security storage', () => {
90
+ const defaults = consentDefaults()
91
+
92
+ expect(defaults.analytics_storage).toBe('denied')
93
+ expect(defaults.ad_storage).toBe('denied')
94
+ expect(defaults.ad_user_data).toBe('denied')
95
+ expect(defaults.ad_personalization).toBe('denied')
96
+ expect(defaults.security_storage).toBe('granted')
97
+ })
98
+
99
+ test('an update maps each category onto the signals it drives', () => {
100
+ const update = consentUpdate({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
101
+
102
+ expect(update.analytics_storage).toBe('granted')
103
+ expect(update.ad_storage).toBe('denied')
104
+ // Required categories are granted whether or not the record says so.
105
+ expect(update.security_storage).toBe('granted')
106
+ })
107
+
108
+ test('a custom category set drives only the signals it names', () => {
109
+ const categories: ConsentCategory[] = [
110
+ { key: 'ads', labelKey: 'a', descriptionKey: 'b', signals: ['ad_storage'] },
111
+ ]
112
+
113
+ expect(Object.keys(consentDefaults(categories))).toEqual(['ad_storage'])
114
+ expect(consentUpdate({ ads: true }, categories)).toEqual({ ad_storage: 'granted' })
115
+ })
116
+ })
117
+
118
+ describe('the bootstrap script', () => {
119
+ const script = consentBootstrapScript()
120
+
121
+ test('declares the defaults before anything else can', () => {
122
+ expect(script).toContain("'consent','default'")
123
+ expect(script.indexOf("'consent','default'")).toBeLessThan(script.indexOf("'consent','update'"))
124
+ })
125
+
126
+ test('is idempotent through the setup flag', () => {
127
+ expect(script).toContain('cookieConsentSetup')
128
+ })
129
+
130
+ test('reads the same storage key the package writes', () => {
131
+ expect(script).toContain(JSON.stringify(CONSENT_KEY))
132
+ })
133
+
134
+ test('pushes arguments, the shape gtag.js itself emits', () => {
135
+ expect(script).toContain('w.dataLayer.push(arguments)')
136
+ })
137
+ })
138
+
139
+ describe('the store', () => {
140
+ test('opens when nothing is stored, and stays shut when something is', () => {
141
+ const first = makeConsentStore()
142
+ first.init({ silent: true })
143
+
144
+ expect(first.get().open).toBe(true)
145
+ expect(first.get().reason).toBe('initial')
146
+
147
+ first.save({ [CONSENT_ANALYTICS]: true })
148
+
149
+ const second = makeConsentStore()
150
+ second.init({ silent: true })
151
+
152
+ expect(second.get().open).toBe(false)
153
+ })
154
+
155
+ test('a required category is granted whether or not the record says so', () => {
156
+ const store = makeConsentStore()
157
+ store.init({ silent: true })
158
+ store.save({ [CONSENT_ANALYTICS]: false })
159
+
160
+ expect(store.granted(CONSENT_ESSENTIAL)).toBe(true)
161
+ expect(store.granted(CONSENT_ANALYTICS)).toBe(false)
162
+ })
163
+
164
+ test('accept-all grants every category', () => {
165
+ const store = makeConsentStore()
166
+ store.init({ silent: true })
167
+ store.acceptAll()
168
+
169
+ expect(DEFAULT_CONSENT_CATEGORIES.every(c => store.granted(c.key))).toBe(true)
170
+ })
171
+
172
+ test('subscribers see every transition', () => {
173
+ const store = makeConsentStore()
174
+ const seen: boolean[] = []
175
+ store.subscribe(state => seen.push(state.open))
176
+ store.init({ silent: true })
177
+ store.acceptAll()
178
+ store.open('reopen')
179
+
180
+ expect(seen).toEqual([true, false, true])
181
+ })
182
+ })
183
+
184
+ describe('the built-in translations', () => {
185
+ test('every locale carries every key of English', () => {
186
+ // Without this, a language added to the bundle ships with English strings in it and nothing
187
+ // reports it — the readers who cannot tell us are exactly the ones affected.
188
+ const keys = Object.keys(require('../src/i18n/en.json') as Record<string, string>)
189
+ for (const lng of ['pl', 'ru', 'be', 'uk', 'es', 'de']) {
190
+ const bundle = require(`../src/i18n/${lng}.json`) as Record<string, string>
191
+ expect({ lng, missing: keys.filter(key => bundle[key] == null) })
192
+ .toEqual({ lng, missing: [] })
193
+ }
194
+ })
195
+
196
+ test('an unknown locale falls back to English rather than to a raw key', () => {
197
+ expect(normalizeLocale('kl')).toBe('en')
198
+ expect(defaultConsentTranslate('kl')('title', 'fallback')).toBe('Cookie Preferences')
199
+ })
200
+
201
+ test('a key nobody translated returns the supplied default', () => {
202
+ expect(defaultConsentTranslate('en')('nope', 'the default')).toBe('the default')
203
+ })
204
+
205
+ test('placeholders interpolate', () => {
206
+ expect(interpolate('kept for {{days}} days', { days: 365 })).toBe('kept for 365 days')
207
+ })
208
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json"
4
+ ],
5
+ "compilerOptions": {
6
+ "lib": ["ESNext", "DOM"],
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
+ }