@owlmeans/consent 0.1.18-rc.10

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 (76) hide show
  1. package/build/consts.d.ts +64 -0
  2. package/build/consts.d.ts.map +1 -0
  3. package/build/consts.js +90 -0
  4. package/build/consts.js.map +1 -0
  5. package/build/consts.local.d.ts +4 -0
  6. package/build/consts.local.d.ts.map +1 -0
  7. package/build/consts.local.js +4 -0
  8. package/build/consts.local.js.map +1 -0
  9. package/build/gtm.d.ts +72 -0
  10. package/build/gtm.d.ts.map +1 -0
  11. package/build/gtm.js +182 -0
  12. package/build/gtm.js.map +1 -0
  13. package/build/i18n/be.json +36 -0
  14. package/build/i18n/de.json +36 -0
  15. package/build/i18n/en.json +36 -0
  16. package/build/i18n/es.json +36 -0
  17. package/build/i18n/fr.json +36 -0
  18. package/build/i18n/pl.json +36 -0
  19. package/build/i18n/ru.json +36 -0
  20. package/build/i18n/uk.json +36 -0
  21. package/build/i18n.d.ts +16 -0
  22. package/build/i18n.d.ts.map +1 -0
  23. package/build/i18n.js +29 -0
  24. package/build/i18n.js.map +1 -0
  25. package/build/index.d.ts +12 -0
  26. package/build/index.d.ts.map +1 -0
  27. package/build/index.js +9 -0
  28. package/build/index.js.map +1 -0
  29. package/build/linker.d.ts +55 -0
  30. package/build/linker.d.ts.map +1 -0
  31. package/build/linker.js +334 -0
  32. package/build/linker.js.map +1 -0
  33. package/build/plugins.d.ts +30 -0
  34. package/build/plugins.d.ts.map +1 -0
  35. package/build/plugins.js +73 -0
  36. package/build/plugins.js.map +1 -0
  37. package/build/storage.d.ts +27 -0
  38. package/build/storage.d.ts.map +1 -0
  39. package/build/storage.js +86 -0
  40. package/build/storage.js.map +1 -0
  41. package/build/store.d.ts +16 -0
  42. package/build/store.d.ts.map +1 -0
  43. package/build/store.js +100 -0
  44. package/build/store.js.map +1 -0
  45. package/build/types.d.ts +205 -0
  46. package/build/types.d.ts.map +1 -0
  47. package/build/types.js +2 -0
  48. package/build/types.js.map +1 -0
  49. package/build/types.local.d.ts +5 -0
  50. package/build/types.local.d.ts.map +1 -0
  51. package/build/types.local.js +2 -0
  52. package/build/types.local.js.map +1 -0
  53. package/package.json +33 -0
  54. package/src/consts.local.ts +4 -0
  55. package/src/consts.ts +103 -0
  56. package/src/gtm.ts +216 -0
  57. package/src/i18n/be.json +36 -0
  58. package/src/i18n/de.json +36 -0
  59. package/src/i18n/en.json +36 -0
  60. package/src/i18n/es.json +36 -0
  61. package/src/i18n/fr.json +36 -0
  62. package/src/i18n/pl.json +36 -0
  63. package/src/i18n/ru.json +36 -0
  64. package/src/i18n/uk.json +36 -0
  65. package/src/i18n.ts +39 -0
  66. package/src/index.ts +14 -0
  67. package/src/linker.ts +369 -0
  68. package/src/plugins.ts +88 -0
  69. package/src/storage.ts +96 -0
  70. package/src/store.ts +126 -0
  71. package/src/types.local.ts +4 -0
  72. package/src/types.ts +218 -0
  73. package/tests/consent.spec.ts +256 -0
  74. package/tests/linker.spec.ts +774 -0
  75. package/tests/tracking.spec.ts +191 -0
  76. package/tsconfig.json +11 -0
@@ -0,0 +1,36 @@
1
+ {
2
+ "title": "Налаштування файлів cookie",
3
+ "description": "Ми використовуємо файли cookie, щоб покращити ваш досвід перегляду, показувати персоналізовану рекламу чи вміст і аналізувати наш трафік. Натискаючи «Прийняти всі», ви погоджуєтеся на використання файлів cookie.",
4
+ "essential": "Необхідні файли cookie",
5
+ "essentialDesc": "Потрібні для роботи сайту — вхід, безпека та збереження вашого вибору щодо файлів cookie і мови інтерфейсу. Їх не можна вимкнути.",
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
+ "policyServicesOf": "Сервіси — {{category}}",
26
+ "policyOtherServices": "Інші сервіси",
27
+ "policyOtherServicesDesc": "Сервіси, які цей застосунок використовує поза переліченими вище категоріями.",
28
+ "policyProvider": "Постачальник",
29
+ "policyPurpose": "Мета",
30
+ "policyCookies": "Файли cookie",
31
+ "policyServicePrivacy": "Політика конфіденційності: {{provider}}",
32
+ "privacy": "Політика конфіденційності",
33
+ "terms": "Умови користування",
34
+ "domains": "Цей вибір застосовується до {{domains}}.",
35
+ "policyDomains": "Цей вибір застосовується до таких доменів:"
36
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { CONSENT_LOCALES } from './consts.js'
2
+ import type { ConsentLocale } from './types.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
+ import fr from './i18n/fr.json' with { type: 'json' }
12
+
13
+ /**
14
+ * The dialog's copy, in the box.
15
+ *
16
+ * Carried here rather than registered into an i18n framework because one of the three consumers is
17
+ * an Astro site with React islands and no OwlMeans i18n at all — and a consent dialog that renders
18
+ * raw keys is worse than no dialog. An application that HAS translations passes `translate` and
19
+ * overrides every one of these.
20
+ */
21
+ export const DEFAULT_CONSENT_MESSAGES: Record<ConsentLocale, Record<string, string>> = {
22
+ en, pl, ru, be, uk, es, de, fr,
23
+ }
24
+
25
+ export const normalizeLocale = (locale?: string): ConsentLocale => {
26
+ const base = (locale ?? 'en').toLowerCase().split('-')[0]
27
+
28
+ return CONSENT_LOCALES.includes(base as ConsentLocale) ? base as ConsentLocale : 'en'
29
+ }
30
+
31
+ /** `(key, defaultValue) => string`, the house shape, backed by the built-in bundle. */
32
+ export const defaultConsentTranslate = (locale?: string) =>
33
+ (key: string, defaultValue: string): string =>
34
+ DEFAULT_CONSENT_MESSAGES[normalizeLocale(locale)]?.[key] ?? defaultValue
35
+
36
+ /** Substitute `{{name}}` placeholders. Enough for the two the policy page needs. */
37
+ export const interpolate = (text: string, values: Record<string, string | number>): string =>
38
+ text.replace(/\{\{([a-z]+)\}\}/gi, (whole, name: string) =>
39
+ values[name] != null ? String(values[name]) : whole)
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
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'
7
+ export type { ConsentPlugin } from './types.js'
8
+ export {
9
+ registerConsentPlugin, consentPlugins, decorateConsentUrl, consentDomains, adoptConsent,
10
+ startConsentPlugins, adoptConsentLanguage,
11
+ } from './plugins.js'
12
+ export type { ConsentLinkPayload } from './types.js'
13
+ export { consentLinker, encodeConsentLink, decodeConsentLink, stripConsentLinkParam, consentLinkerScript, writeConsentLanguage } from './linker.js'
14
+ export { CONSENT_LINK_PARAM, CONSENT_LINK_MAX_AGE, CONSENT_LINK_SKEW } from './consts.js'
package/src/linker.ts ADDED
@@ -0,0 +1,369 @@
1
+ import { CONSENT_COOKIE_DAYS, CONSENT_KEY, CONSENT_LANGUAGE_KEY, CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES, CONSENT_LINK_MAX_AGE, CONSENT_LINK_PARAM, CONSENT_LINK_SKEW } from './consts.js'
2
+ import type { ConsentPlugin, ConsentCategory, ConsentOptions, ConsentRecord, ConsentLinkPayload } from './types.js'
3
+ import { decorateConsentUrl } from './plugins.js'
4
+ import { readConsent } from './storage.js'
5
+ import { CONSENT_LINK_VERSION, LANGUAGE_TAG } from './consts.local.js'
6
+
7
+
8
+ const categoriesOf = (opts?: ConsentOptions): ConsentCategory[] =>
9
+ opts?.categories ?? DEFAULT_CONSENT_CATEGORIES
10
+
11
+ const optionalOf = (opts?: ConsentOptions): ConsentCategory[] =>
12
+ categoriesOf(opts).filter(category => category.required !== true)
13
+
14
+ const toBase64Url = (json: string): string =>
15
+ btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
16
+
17
+ const fromBase64Url = (value: string): string | null => {
18
+ try {
19
+ let padded = value.replace(/-/g, '+').replace(/_/g, '/')
20
+ while (padded.length % 4 !== 0) {
21
+ padded += '='
22
+ }
23
+
24
+ return atob(padded)
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ /** The language this document is written in — `<html lang>`, the one signal every page has. */
31
+ const documentLanguage = (): string | null => {
32
+ if (typeof document === 'undefined') {
33
+ return null
34
+ }
35
+ const lang = document.documentElement?.lang?.trim() ?? ''
36
+
37
+ return LANGUAGE_TAG.test(lang) ? lang.toLowerCase() : null
38
+ }
39
+
40
+ /**
41
+ * The link parameter's value: the current document's decision as `c` (one `0`/`1` per optional
42
+ * category — empty while `record` is `null`, i.e. before the visitor has decided) and, when
43
+ * `opts.linker.language` is set, the page's language as `l`.
44
+ */
45
+ export const encodeConsentLink = (record: ConsentRecord | null, opts?: ConsentOptions): string => {
46
+ const c: Record<string, 0 | 1> = {}
47
+ if (record != null) {
48
+ for (const category of optionalOf(opts)) {
49
+ c[category.key] = record[category.key] === true ? 1 : 0
50
+ }
51
+ }
52
+ const language = opts?.linker?.language != null ? documentLanguage() : null
53
+ const payload: ConsentLinkPayload = {
54
+ v: CONSENT_LINK_VERSION, c, t: Math.floor(Date.now() / 1000), ...(language != null ? { l: language } : {}),
55
+ }
56
+
57
+ return toBase64Url(JSON.stringify(payload))
58
+ }
59
+
60
+ /** Parse-only — no trust decision here, that is `consentLinker(opts).adopt`'s job. Never throws. */
61
+ export const decodeConsentLink = (value: string): ConsentLinkPayload | null => {
62
+ const json = fromBase64Url(value)
63
+ if (json == null) {
64
+ return null
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(json) as unknown
68
+ if (parsed == null || typeof parsed !== 'object') {
69
+ return null
70
+ }
71
+ const { v, c, t, l } = parsed as Record<string, unknown>
72
+ if (v !== CONSENT_LINK_VERSION || typeof t !== 'number' || c == null || typeof c !== 'object') {
73
+ return null
74
+ }
75
+
76
+ return {
77
+ v: CONSENT_LINK_VERSION, c: c as Record<string, 0 | 1>, t,
78
+ // A malformed language is dropped, never a reason to refuse the decision beside it.
79
+ ...(typeof l === 'string' && LANGUAGE_TAG.test(l) ? { l: l.toLowerCase() } : {}),
80
+ }
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ const referrerHostname = (): string | null => {
87
+ if (typeof document === 'undefined' || document.referrer === '') {
88
+ return null
89
+ }
90
+ try {
91
+ return new URL(document.referrer).hostname
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The payload of the link this document was opened from, once it has passed every check that does
99
+ * not depend on WHAT it carries: the parameter decodes and its version matches, its timestamp is
100
+ * fresh (and not from the future beyond the allowed skew), and the request's referrer host is a
101
+ * LISTED domain — read from `document.referrer`, never from the parameter itself. `null` otherwise.
102
+ */
103
+ const trustedPayload = (opts: ConsentOptions): ConsentLinkPayload | null => {
104
+ const linker = opts.linker
105
+ if (linker == null || typeof location === 'undefined') {
106
+ return null
107
+ }
108
+ const raw = new URLSearchParams(location.search).get(linker.param ?? CONSENT_LINK_PARAM)
109
+ if (raw == null) {
110
+ return null
111
+ }
112
+ const decoded = decodeConsentLink(raw)
113
+ if (decoded == null) {
114
+ return null
115
+ }
116
+ const now = Math.floor(Date.now() / 1000)
117
+ const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
118
+ if (now - decoded.t > maxAge || decoded.t - now > CONSENT_LINK_SKEW) {
119
+ return null
120
+ }
121
+ const referrer = referrerHostname()
122
+ if (referrer == null || !linker.domains.includes(referrer)) {
123
+ return null
124
+ }
125
+
126
+ return decoded
127
+ }
128
+
129
+ /** The entry of `supported` that `language` names — exactly, else by its base tag — or `null`. */
130
+ const supportedLanguage = (language: string, supported: string[]): string | null => {
131
+ const wanted = language.toLowerCase()
132
+ const exact = supported.find(candidate => candidate.toLowerCase() === wanted)
133
+ if (exact != null) {
134
+ return exact
135
+ }
136
+ const base = wanted.split(/[-_]/)[0]
137
+
138
+ return supported.find(candidate => candidate.toLowerCase() === base) ?? null
139
+ }
140
+
141
+ /**
142
+ * Persist an adopted language where the application's i18n layer reads its explicit choice from —
143
+ * `linker.language.storageKey`, `owlmeans-lng` by default. Unconditional: the interface language is
144
+ * strictly necessary storage (the visitor picked it, and the site cannot speak to them without it),
145
+ * so no cookie decision — none yet, a refusal, an old record — stands between it and the device.
146
+ *
147
+ * It overwrites what is there on purpose: the carried language is the one the visitor was reading a
148
+ * moment ago, which outranks a choice they made on this domain some other day. Storage that refuses
149
+ * the write is not an error. Returns whether the language was written.
150
+ */
151
+ export const writeConsentLanguage = (language: string, opts?: ConsentOptions): boolean => {
152
+ try {
153
+ localStorage.setItem(opts?.linker?.language?.storageKey ?? CONSENT_LANGUAGE_KEY, language)
154
+
155
+ return true
156
+ } catch {
157
+ // Private modes and blocked storage both throw; the app simply keeps choosing its own language.
158
+ return false
159
+ }
160
+ }
161
+
162
+ /** Escape `<` inside a JSON literal meant for an inline `<script>` — the same discipline every
163
+ * caller of `consentBootstrapScript`/`consentGateScript` already owes the HTML it lands in, applied
164
+ * here at the source since this fragment's dynamic values (a configured domain, a storage key) are
165
+ * least likely to have been reviewed for it by whoever composes the final page. */
166
+ const jsonForScript = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
167
+
168
+ /**
169
+ * Cross-domain consent: share this decision, and adopt one, between the domains named in
170
+ * `opts.linker.domains`.
171
+ *
172
+ * `start` decorates outgoing first-party links; `adopt` reads and validates an incoming one;
173
+ * `domains` discloses the list a dialog or policy page names. `decorate` is generic (a bare `URL`,
174
+ * no DOM) so the same trust rule serves a real anchor click and a programmatic navigation
175
+ * (`decorateConsentUrl`) alike — the `rel="noreferrer"` exemption is a DOM-only concept and is
176
+ * therefore checked by `start`'s click handler, never here.
177
+ */
178
+ export const consentLinker = (): ConsentPlugin => {
179
+ let installed = false
180
+
181
+ return {
182
+ alias: 'linker',
183
+
184
+ start: opts => {
185
+ if (typeof document === 'undefined' || installed || opts.linker == null) {
186
+ return
187
+ }
188
+ installed = true
189
+
190
+ const onNavigate = (event: Event): void => {
191
+ const target = event.target
192
+ const anchor = target instanceof Element ? target.closest('a[href]') : null
193
+ if (!(anchor instanceof HTMLAnchorElement)) {
194
+ return
195
+ }
196
+ // The trust rule keeps `noreferrer` links untouched: a link that refuses to disclose the
197
+ // referrer is refusing exactly the signal the receiving side's `adopt` needs to trust it.
198
+ if (/\bnoreferrer\b/.test(anchor.getAttribute('rel') ?? '')) {
199
+ return
200
+ }
201
+ // No decision yet is still worth a decoration when a language rides along with it.
202
+ const record = readConsent(opts)
203
+ if (record == null && opts.linker?.language == null) {
204
+ return
205
+ }
206
+ let url: URL
207
+ try {
208
+ url = new URL(anchor.href, location.href)
209
+ } catch {
210
+ return
211
+ }
212
+ const decorated = decorateConsentUrl(url, record, opts)
213
+ if (decorated.toString() !== url.toString()) {
214
+ anchor.href = decorated.toString()
215
+ }
216
+ }
217
+
218
+ // Capture phase: this must decide before the click's own navigation (or a framework router
219
+ // intercepting it) reads `href`. `auxclick`/`contextmenu` cover a middle-click and "copy link
220
+ // address" — an address copied undecorated is a link that silently fails to carry the choice.
221
+ document.addEventListener('click', onNavigate, true)
222
+ document.addEventListener('auxclick', onNavigate, true)
223
+ document.addEventListener('contextmenu', onNavigate, true)
224
+ },
225
+
226
+ decorate: (url, record, opts) => {
227
+ const linker = opts.linker
228
+ const host = typeof location !== 'undefined' ? location.hostname : ''
229
+ if (linker == null || url.hostname === host || !linker.domains.includes(url.hostname)) {
230
+ return null
231
+ }
232
+ // A link carries a decision, a language, or both; with neither there is nothing to say.
233
+ if (record == null && (linker.language == null || documentLanguage() == null)) {
234
+ return null
235
+ }
236
+ const next = new URL(url.toString())
237
+ next.searchParams.set(linker.param ?? CONSENT_LINK_PARAM, encodeConsentLink(record, opts))
238
+
239
+ return next
240
+ },
241
+
242
+ adopt: opts => {
243
+ const decoded = trustedPayload(opts)
244
+ if (decoded == null) {
245
+ return null
246
+ }
247
+ const optional = optionalOf(opts)
248
+ if (!optional.every(category => decoded.c[category.key] === 0 || decoded.c[category.key] === 1)) {
249
+ return null
250
+ }
251
+
252
+ const record: ConsentRecord = { v: CONSENT_SCHEMA_VERSION }
253
+ for (const category of categoriesOf(opts)) {
254
+ record[category.key] = category.required === true || decoded.c[category.key] === 1
255
+ }
256
+
257
+ return record
258
+ },
259
+
260
+ adoptLanguage: opts => {
261
+ const supported = opts.linker?.language?.supported
262
+ const decoded = supported != null ? trustedPayload(opts) : null
263
+
264
+ return decoded?.l != null && supported != null ? supportedLanguage(decoded.l, supported) : null
265
+ },
266
+
267
+ domains: opts => opts.linker?.domains ?? [],
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Remove the link parameter from the visible URL, keeping every other parameter and the hash — the
273
+ * real-TS equivalent of what {@link consentLinkerScript}'s embedded fragment already did inline.
274
+ * Idempotent: a page with no head script calls this from `consentStore.init` and it strips once; a
275
+ * page that ALSO carried the head script finds the parameter already gone and does nothing.
276
+ */
277
+ export const stripConsentLinkParam = (opts: ConsentOptions): void => {
278
+ const linker = opts.linker
279
+ if (linker == null || typeof location === 'undefined' || typeof history === 'undefined') {
280
+ return
281
+ }
282
+ const param = linker.param ?? CONSENT_LINK_PARAM
283
+ const url = new URL(location.href)
284
+ if (!url.searchParams.has(param)) {
285
+ return
286
+ }
287
+ url.searchParams.delete(param)
288
+ try {
289
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`)
290
+ } catch {
291
+ // A sandboxed/embedded context may refuse history writes; the parameter just stays visible.
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The inline `<head>` fragment that adopts a decorated link's decision and strips the parameter —
297
+ * standalone, so a page can stamp it even where nothing else here runs (a legal page with no tag
298
+ * script). `@owlmeans/web-gtm`'s `consentBootstrapScript` embeds this SAME fragment right after
299
+ * pushing Consent Mode's defaults, so a page that has both never runs it twice with different
300
+ * effect — the second attempt finds the parameter already gone and does nothing.
301
+ *
302
+ * Mirrors `consentLinker().adopt` exactly (v2, referrer + freshness/skew + full optional coverage +
303
+ * no existing record), but as hand-rolled JS text rather than a call into this module — the same
304
+ * discipline `consentBootstrapScript`/`consentGateScript` already follow, because this has to run
305
+ * before any bundle (this module included) has loaded. The parameter is ALWAYS stripped via
306
+ * `history.replaceState`, whether or not the trust rule accepts it: a stale or foreign `owlcc` is
307
+ * exactly as much noise as an adopted one, and neither belongs in the visible URL or in what a page
308
+ * reload, a shared link or a browser history entry carries forward.
309
+ */
310
+ export const consentLinkerScript = (opts: ConsentOptions): string => {
311
+ const linker = opts.linker
312
+ if (linker == null) {
313
+ return ''
314
+ }
315
+ const param = jsonForScript(linker.param ?? CONSENT_LINK_PARAM)
316
+ const domains = jsonForScript(linker.domains)
317
+ const storageKey = jsonForScript(opts.storageKey ?? CONSENT_KEY)
318
+ const optionalKeys = jsonForScript(optionalOf(opts).map(category => category.key))
319
+ const allCategories = jsonForScript(
320
+ categoriesOf(opts).map(category => ({ key: category.key, required: category.required === true }))
321
+ )
322
+ const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
323
+ const skew = CONSENT_LINK_SKEW
324
+ const cookieDays = opts.cookieDays ?? CONSENT_COOKIE_DAYS
325
+ const version = CONSENT_LINK_VERSION
326
+ // The language rides the same trust decision and is written unconditionally — it is strictly
327
+ // necessary storage, whatever the cookie decision is. Mirrors `supportedLanguage` +
328
+ // `writeConsentLanguage`; empty for a page that does not receive a language.
329
+ const language = linker.language?.supported != null
330
+ ? `var L=${jsonForScript(linker.language.supported)};` +
331
+ `if(typeof j.l==='string'){var lc=j.l.toLowerCase(),lm=null,q;` +
332
+ `for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}` +
333
+ `if(!lm){lc=lc.split(/[-_]/)[0];for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}}` +
334
+ `if(lm){try{w.localStorage.setItem(${jsonForScript(linker.language.storageKey ?? CONSENT_LANGUAGE_KEY)},lm)}catch(e){}}}`
335
+ : ''
336
+
337
+ return `(function(w,d){` +
338
+ `var p=${param};var qs;try{qs=new URLSearchParams(location.search)}catch(e){return}` +
339
+ `var raw=qs.get(p);if(raw==null)return;` +
340
+ `qs.delete(p);var rest=qs.toString();` +
341
+ `var url=location.pathname+(rest?('?'+rest):'')+location.hash;` +
342
+ `try{history.replaceState(history.state,'',url)}catch(e){}` +
343
+ `try{` +
344
+ `var b=raw.replace(/-/g,'+').replace(/_/g,'/');while(b.length%4)b+='=';` +
345
+ `var j=JSON.parse(atob(b));` +
346
+ `if(!j||j.v!==${version}||typeof j.t!=='number'||!j.c||typeof j.c!=='object')return;` +
347
+ `var now=Math.floor(Date.now()/1000);` +
348
+ `if(now-j.t>${maxAge}||j.t-now>${skew})return;` +
349
+ `var ref=null;try{ref=new URL(d.referrer).hostname}catch(e){}` +
350
+ `if(!ref||${domains}.indexOf(ref)<0)return;` +
351
+ `var existing=null;try{existing=w.localStorage.getItem(${storageKey})}catch(e){}` +
352
+ `if(!existing){var cp=('; '+d.cookie).split('; '+${storageKey}+'=');` +
353
+ `if(cp.length===2)existing=cp.pop().split(';').shift()}` +
354
+ `if(!existing){` +
355
+ `var keys=${optionalKeys},ok=true;` +
356
+ `for(var i=0;i<keys.length;i++){if(j.c[keys[i]]!==0&&j.c[keys[i]]!==1){ok=false;break}}` +
357
+ `if(ok){` +
358
+ `var cats=${allCategories},rec={v:${version}};` +
359
+ `for(var k=0;k<cats.length;k++){var c=cats[k];rec[c.key]=c.required||j.c[c.key]===1}` +
360
+ `var val=JSON.stringify(rec);` +
361
+ `try{w.localStorage.setItem(${storageKey},val)}catch(e){}` +
362
+ `var exp=new Date(Date.now()+${cookieDays}*86400000).toUTCString();` +
363
+ `d.cookie=${storageKey}+'='+val+';expires='+exp+';path=/;SameSite=Lax'` +
364
+ `}` +
365
+ `}` +
366
+ language +
367
+ `}catch(e){}` +
368
+ `})(window,document)`
369
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { ConsentOptions, ConsentRecord, ConsentPlugin } from './types.js'
2
+
3
+ /**
4
+ * Module-global by design, like `@owlmeans/client-auth/login`'s method/step registries: consent is
5
+ * one thing per document, not per component tree, and a plugin is installed once at bundle scope
6
+ * (an Astro island, a script tag, a React app) regardless of how many components read the store.
7
+ */
8
+ const plugins = new Map<string, ConsentPlugin>()
9
+
10
+ /** Register a plugin. Replace-by-alias; sorted by priority (higher first) on every read. */
11
+ export const registerConsentPlugin = (plugin: ConsentPlugin): void => {
12
+ plugins.set(plugin.alias, plugin)
13
+ }
14
+
15
+ /** Every registered plugin, priority-sorted (higher first, ties keep insertion order). */
16
+ export const consentPlugins = (): ConsentPlugin[] =>
17
+ [...plugins.values()].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
18
+
19
+ /**
20
+ * Ask every registered plugin to decorate `url` with this document's decision, in priority order,
21
+ * stopping at the first one that actually changes it. A plugin whose own rules refuse (a foreign
22
+ * host, a `noreferrer` link, no local decision yet) returns `null` and is skipped, not an error.
23
+ */
24
+ export const decorateConsentUrl = (
25
+ url: string | URL, record: ConsentRecord | null, opts: ConsentOptions
26
+ ): URL => {
27
+ let result = url instanceof URL ? url : new URL(url)
28
+ for (const plugin of consentPlugins()) {
29
+ if (plugin.decorate == null) {
30
+ continue
31
+ }
32
+ const decorated = plugin.decorate(result, record, opts)
33
+ if (decorated != null) {
34
+ result = decorated
35
+ }
36
+ }
37
+
38
+ return result
39
+ }
40
+
41
+ /**
42
+ * Every domain this document's decision is disclosed to, current host first, deduplicated — what a
43
+ * dialog or a policy page lists. A plugin that runs on this host but names no domains of its own
44
+ * (nothing registered, or `domains` not implemented) leaves the list at just the current host,
45
+ * which is always correct to show even with no plugin at all.
46
+ */
47
+ export const consentDomains = (opts: ConsentOptions): string[] => {
48
+ const host = typeof location !== 'undefined' ? location.hostname : ''
49
+ const named = consentPlugins().flatMap(plugin => plugin.domains?.(opts) ?? [])
50
+
51
+ return [...new Set([host, ...named].filter(domain => domain !== ''))]
52
+ }
53
+
54
+ /** Try every registered plugin's `adopt`, in priority order, and use the first decision offered. */
55
+ export const adoptConsent = (opts: ConsentOptions): ConsentRecord | null => {
56
+ for (const plugin of consentPlugins()) {
57
+ const adopted = plugin.adopt?.(opts)
58
+ if (adopted != null) {
59
+ return adopted
60
+ }
61
+ }
62
+
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * Try every registered plugin's `adoptLanguage`, in priority order, and use the first language
68
+ * offered. The caller persists it (`writeConsentLanguage`) — and has to do that BEFORE its i18n
69
+ * layer reads storage, which is why the inline `<head>` fragment (`consentLinkerScript`) is the
70
+ * one that normally does.
71
+ */
72
+ export const adoptConsentLanguage = (opts: ConsentOptions): string | null => {
73
+ for (const plugin of consentPlugins()) {
74
+ const adopted = plugin.adoptLanguage?.(opts)
75
+ if (adopted != null) {
76
+ return adopted
77
+ }
78
+ }
79
+
80
+ return null
81
+ }
82
+
83
+ /** Run every registered plugin's `start`, in priority order. `consentStore.init` calls this once. */
84
+ export const startConsentPlugins = (opts: ConsentOptions): void => {
85
+ for (const plugin of consentPlugins()) {
86
+ plugin.start?.(opts)
87
+ }
88
+ }
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
+ }