@owlmeans/consent 0.1.18-rc.8 → 0.1.18

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 (120) hide show
  1. package/README.md +229 -0
  2. package/build/consts.d.ts +84 -23
  3. package/build/consts.d.ts.map +1 -1
  4. package/build/consts.js +110 -29
  5. package/build/consts.js.map +1 -1
  6. package/build/consts.local.d.ts +12 -0
  7. package/build/consts.local.d.ts.map +1 -0
  8. package/build/consts.local.js +12 -0
  9. package/build/consts.local.js.map +1 -0
  10. package/build/geo/types.d.ts +47 -0
  11. package/build/geo/types.d.ts.map +1 -0
  12. package/build/geo/types.js +2 -0
  13. package/build/geo/types.js.map +1 -0
  14. package/build/geo.d.ts +4 -0
  15. package/build/geo.d.ts.map +1 -0
  16. package/build/geo.js +96 -0
  17. package/build/geo.js.map +1 -0
  18. package/build/gtobserver/types.d.ts +75 -0
  19. package/build/gtobserver/types.d.ts.map +1 -0
  20. package/build/gtobserver/types.js +2 -0
  21. package/build/gtobserver/types.js.map +1 -0
  22. package/build/gtobserver.d.ts +7 -0
  23. package/build/gtobserver.d.ts.map +1 -0
  24. package/build/gtobserver.js +142 -0
  25. package/build/gtobserver.js.map +1 -0
  26. package/build/i18n/be.json +9 -4
  27. package/build/i18n/de.json +9 -4
  28. package/build/i18n/en.json +9 -4
  29. package/build/i18n/es.json +9 -4
  30. package/build/i18n/fr.json +9 -4
  31. package/build/i18n/pl.json +9 -4
  32. package/build/i18n/ru.json +9 -4
  33. package/build/i18n/types.d.ts +11 -0
  34. package/build/i18n/types.d.ts.map +1 -0
  35. package/build/i18n/types.js +2 -0
  36. package/build/i18n/types.js.map +1 -0
  37. package/build/i18n/uk.json +9 -4
  38. package/build/i18n.d.ts +4 -14
  39. package/build/i18n.d.ts.map +1 -1
  40. package/build/i18n.js +12 -27
  41. package/build/i18n.js.map +1 -1
  42. package/build/index.d.ts +13 -6
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +5 -4
  45. package/build/index.js.map +1 -1
  46. package/build/linker/types.d.ts +59 -0
  47. package/build/linker/types.d.ts.map +1 -0
  48. package/build/linker/types.js +2 -0
  49. package/build/linker/types.js.map +1 -0
  50. package/build/linker.d.ts +5 -76
  51. package/build/linker.d.ts.map +1 -1
  52. package/build/linker.js +290 -339
  53. package/build/linker.js.map +1 -1
  54. package/build/plugins/types.d.ts +42 -0
  55. package/build/plugins/types.d.ts.map +1 -0
  56. package/build/plugins/types.js +2 -0
  57. package/build/plugins/types.js.map +1 -0
  58. package/build/plugins.d.ts +4 -69
  59. package/build/plugins.d.ts.map +1 -1
  60. package/build/plugins.js +73 -62
  61. package/build/plugins.js.map +1 -1
  62. package/build/storage/types.d.ts +31 -0
  63. package/build/storage/types.d.ts.map +1 -0
  64. package/build/storage/types.js +2 -0
  65. package/build/storage/types.js.map +1 -0
  66. package/build/storage.d.ts +6 -22
  67. package/build/storage.d.ts.map +1 -1
  68. package/build/storage.js +72 -81
  69. package/build/storage.js.map +1 -1
  70. package/build/store.d.ts +2 -2
  71. package/build/store.d.ts.map +1 -1
  72. package/build/store.js +116 -67
  73. package/build/store.js.map +1 -1
  74. package/build/types.d.ts +147 -26
  75. package/build/types.d.ts.map +1 -1
  76. package/build/types.local.d.ts +9 -0
  77. package/build/types.local.d.ts.map +1 -0
  78. package/build/types.local.js +2 -0
  79. package/build/types.local.js.map +1 -0
  80. package/package.json +3 -3
  81. package/src/consts.local.ts +16 -0
  82. package/src/consts.ts +126 -34
  83. package/src/geo/types.ts +49 -0
  84. package/src/geo.ts +122 -0
  85. package/src/gtobserver/types.ts +73 -0
  86. package/src/gtobserver.ts +182 -0
  87. package/src/i18n/be.json +9 -4
  88. package/src/i18n/de.json +9 -4
  89. package/src/i18n/en.json +9 -4
  90. package/src/i18n/es.json +9 -4
  91. package/src/i18n/fr.json +9 -4
  92. package/src/i18n/pl.json +9 -4
  93. package/src/i18n/ru.json +9 -4
  94. package/src/i18n/types.ts +11 -0
  95. package/src/i18n/uk.json +9 -4
  96. package/src/i18n.ts +20 -32
  97. package/src/index.ts +13 -13
  98. package/src/linker/types.ts +59 -0
  99. package/src/linker.ts +301 -364
  100. package/src/plugins/types.ts +42 -0
  101. package/src/plugins.ts +82 -106
  102. package/src/storage/types.ts +31 -0
  103. package/src/storage.ts +75 -77
  104. package/src/store.ts +122 -69
  105. package/src/types.local.ts +9 -0
  106. package/src/types.ts +153 -25
  107. package/tests/consent.spec.ts +26 -28
  108. package/tests/geo.spec.ts +474 -0
  109. package/tests/linker.spec.ts +144 -309
  110. package/tests/tracking.spec.ts +14 -14
  111. package/build/functional.d.ts +0 -35
  112. package/build/functional.d.ts.map +0 -1
  113. package/build/functional.js +0 -67
  114. package/build/functional.js.map +0 -1
  115. package/build/gtm.d.ts +0 -72
  116. package/build/gtm.d.ts.map +0 -1
  117. package/build/gtm.js +0 -182
  118. package/build/gtm.js.map +0 -1
  119. package/src/functional.ts +0 -73
  120. package/src/gtm.ts +0 -220
package/src/linker.ts CHANGED
@@ -1,413 +1,350 @@
1
- import {
2
- CONSENT_COOKIE_DAYS, CONSENT_FUNCTIONAL, CONSENT_KEY, CONSENT_LANGUAGE_KEY, CONSENT_PENDING_LANGUAGE,
3
- CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES,
4
- } from './consts.js'
5
- import { functionalGranted } from './functional.js'
6
- import type { ConsentPlugin } from './plugins.js'
7
- import { decorateConsentUrl } from './plugins.js'
8
- import { readConsent } from './storage.js'
9
- import type { ConsentCategory, ConsentOptions, ConsentRecord } from './types.js'
10
-
11
- /** The URL parameter a decorated link carries the decision in, absent an override. */
12
- export const CONSENT_LINK_PARAM = 'owlcc'
13
- /** How old a decorated link's timestamp may be and still be trusted, in seconds, absent an override. */
14
- export const CONSENT_LINK_MAX_AGE = 300
15
- /** Clock skew allowed the OTHER way — a timestamp up to this far in the future is still trusted. */
16
- export const CONSENT_LINK_SKEW = 60
17
-
18
- const CONSENT_LINK_VERSION = 2
19
-
20
- /** The decoded, not-yet-validated payload a decorated link's parameter carries. */
21
- export interface ConsentLinkPayload {
22
- v: typeof CONSENT_LINK_VERSION
23
- /** One entry per OPTIONAL category this site knows, `1` granted / `0` denied. */
24
- c: Record<string, 0 | 1>
25
- /** Unix seconds the link was decorated at. */
26
- t: number
27
- /**
28
- * The interface language of the page the link was on (a BCP 47 tag, lower-cased) — present only
29
- * when the sender has `linker.language` set and its `<html lang>` names one. Optional on the
30
- * wire, so a receiver that predates it reads the same `v: 2` payload and ignores the field.
31
- */
32
- l?: string
33
- }
34
-
35
- /** A BCP 47-shaped tag: a 2–3 letter language and up to three subtags (`pl`, `pt-BR`, `zh-Hant-TW`). */
36
- const LANGUAGE_TAG = /^[a-z]{2,3}(?:[-_][a-z0-9]{1,8}){0,3}$/i
37
-
38
- const categoriesOf = (opts?: ConsentOptions): ConsentCategory[] =>
39
- opts?.categories ?? DEFAULT_CONSENT_CATEGORIES
40
-
41
- const optionalOf = (opts?: ConsentOptions): ConsentCategory[] =>
42
- categoriesOf(opts).filter(category => category.required !== true)
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 { consentPluginHelper } from './plugins.js'
4
+ import { consentStorageHelper } from './storage.js'
5
+ import { CONSENT_LINK_VERSION, LANGUAGE_TAG } from './consts.local.js'
6
+ import type { ConsentLinkHelper } from './linker/types.js'
7
+
8
+ export const createConsentLinkHelper = (): ConsentLinkHelper => {
9
+ const categoriesOf = (opts?: ConsentOptions): ConsentCategory[] =>
10
+ opts?.categories ?? DEFAULT_CONSENT_CATEGORIES
11
+
12
+ const optionalOf = (opts?: ConsentOptions): ConsentCategory[] =>
13
+ categoriesOf(opts).filter(category => category.required !== true)
14
+
15
+ const toBase64Url = (json: string): string =>
16
+ btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
17
+
18
+ const fromBase64Url = (value: string): string | null => {
19
+ try {
20
+ let padded = value.replace(/-/g, '+').replace(/_/g, '/')
21
+ while (padded.length % 4 !== 0) {
22
+ padded += '='
23
+ }
43
24
 
44
- const toBase64Url = (json: string): string =>
45
- btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
25
+ return atob(padded)
26
+ } catch {
27
+ return null
28
+ }
29
+ }
46
30
 
47
- const fromBase64Url = (value: string): string | null => {
48
- try {
49
- let padded = value.replace(/-/g, '+').replace(/_/g, '/')
50
- while (padded.length % 4 !== 0) {
51
- padded += '='
31
+ /** The language this document is written in — `<html lang>`, the one signal every page has. */
32
+ const documentLanguage = (): string | null => {
33
+ if (typeof document === 'undefined') {
34
+ return null
52
35
  }
36
+ const lang = document.documentElement?.lang?.trim() ?? ''
53
37
 
54
- return atob(padded)
55
- } catch {
56
- return null
38
+ return LANGUAGE_TAG.test(lang) ? lang.toLowerCase() : null
57
39
  }
58
- }
59
40
 
60
- /** The language this document is written in — `<html lang>`, the one signal every page has. */
61
- const documentLanguage = (): string | null => {
62
- if (typeof document === 'undefined') {
63
- return null
41
+ /**
42
+ * The visitor's OWN decision, or `null`. An automatic one (`auto`) was derived from where they
43
+ * browsed from and nobody chose it, so it never travels as their choice — the receiving domain
44
+ * locates them itself.
45
+ */
46
+ const explicitOf = (record: ConsentRecord | null): ConsentRecord | null =>
47
+ record != null && typeof record.auto !== 'number' ? record : null
48
+
49
+ const encodeConsentLink = (input: ConsentRecord | null, opts?: ConsentOptions): string => {
50
+ const c: Record<string, 0 | 1> = {}
51
+ const record = explicitOf(input)
52
+ if (record != null) {
53
+ for (const category of optionalOf(opts)) {
54
+ c[category.key] = record[category.key] === true ? 1 : 0
55
+ }
56
+ }
57
+ const language = opts?.linker?.language != null ? documentLanguage() : null
58
+ const payload: ConsentLinkPayload = {
59
+ v: CONSENT_LINK_VERSION, c, t: Math.floor(Date.now() / 1000), ...(language != null ? { l: language } : {}),
60
+ }
61
+
62
+ return toBase64Url(JSON.stringify(payload))
64
63
  }
65
- const lang = document.documentElement?.lang?.trim() ?? ''
66
64
 
67
- return LANGUAGE_TAG.test(lang) ? lang.toLowerCase() : null
68
- }
65
+ const decodeConsentLink = (value: string): ConsentLinkPayload | null => {
66
+ const json = fromBase64Url(value)
67
+ if (json == null) {
68
+ return null
69
+ }
70
+ try {
71
+ const parsed = JSON.parse(json) as unknown
72
+ if (parsed == null || typeof parsed !== 'object') {
73
+ return null
74
+ }
75
+ const { v, c, t, l } = parsed as Record<string, unknown>
76
+ if (v !== CONSENT_LINK_VERSION || typeof t !== 'number' || c == null || typeof c !== 'object') {
77
+ return null
78
+ }
69
79
 
70
- /**
71
- * The link parameter's value: the current document's decision as `c` (one `0`/`1` per optional
72
- * category — empty while `record` is `null`, i.e. before the visitor has decided) and, when
73
- * `opts.linker.language` is set, the page's language as `l`.
74
- */
75
- export const encodeConsentLink = (record: ConsentRecord | null, opts?: ConsentOptions): string => {
76
- const c: Record<string, 0 | 1> = {}
77
- if (record != null) {
78
- for (const category of optionalOf(opts)) {
79
- c[category.key] = record[category.key] === true ? 1 : 0
80
+ return {
81
+ v: CONSENT_LINK_VERSION, c: c as Record<string, 0 | 1>, t,
82
+ // A malformed language is dropped, never a reason to refuse the decision beside it.
83
+ ...(typeof l === 'string' && LANGUAGE_TAG.test(l) ? { l: l.toLowerCase() } : {}),
84
+ }
85
+ } catch {
86
+ return null
80
87
  }
81
88
  }
82
- const language = opts?.linker?.language != null ? documentLanguage() : null
83
- const payload: ConsentLinkPayload = {
84
- v: CONSENT_LINK_VERSION, c, t: Math.floor(Date.now() / 1000), ...(language != null ? { l: language } : {}),
85
- }
86
89
 
87
- return toBase64Url(JSON.stringify(payload))
88
- }
89
-
90
- /** Parse-only — no trust decision here, that is `consentLinker(opts).adopt`'s job. Never throws. */
91
- export const decodeConsentLink = (value: string): ConsentLinkPayload | null => {
92
- const json = fromBase64Url(value)
93
- if (json == null) {
94
- return null
95
- }
96
- try {
97
- const parsed = JSON.parse(json) as unknown
98
- if (parsed == null || typeof parsed !== 'object') {
90
+ const referrerHostname = (): string | null => {
91
+ if (typeof document === 'undefined' || document.referrer === '') {
99
92
  return null
100
93
  }
101
- const { v, c, t, l } = parsed as Record<string, unknown>
102
- if (v !== CONSENT_LINK_VERSION || typeof t !== 'number' || c == null || typeof c !== 'object') {
94
+ try {
95
+ return new URL(document.referrer).hostname
96
+ } catch {
103
97
  return null
104
98
  }
105
-
106
- return {
107
- v: CONSENT_LINK_VERSION, c: c as Record<string, 0 | 1>, t,
108
- // A malformed language is dropped, never a reason to refuse the decision beside it.
109
- ...(typeof l === 'string' && LANGUAGE_TAG.test(l) ? { l: l.toLowerCase() } : {}),
110
- }
111
- } catch {
112
- return null
113
99
  }
114
- }
115
100
 
116
- const referrerHostname = (): string | null => {
117
- if (typeof document === 'undefined' || document.referrer === '') {
118
- return null
119
- }
120
- try {
121
- return new URL(document.referrer).hostname
122
- } catch {
123
- return null
124
- }
125
- }
101
+ /**
102
+ * The payload of the link this document was opened from, once it has passed every check that does
103
+ * not depend on WHAT it carries: the parameter decodes and its version matches, its timestamp is
104
+ * fresh (and not from the future beyond the allowed skew), and the request's referrer host is a
105
+ * LISTED domain — read from `document.referrer`, never from the parameter itself. `null` otherwise.
106
+ */
107
+ const trustedPayload = (opts: ConsentOptions): ConsentLinkPayload | null => {
108
+ const linker = opts.linker
109
+ if (linker == null || typeof location === 'undefined') {
110
+ return null
111
+ }
112
+ const raw = new URLSearchParams(location.search).get(linker.param ?? CONSENT_LINK_PARAM)
113
+ if (raw == null) {
114
+ return null
115
+ }
116
+ const decoded = decodeConsentLink(raw)
117
+ if (decoded == null) {
118
+ return null
119
+ }
120
+ const now = Math.floor(Date.now() / 1000)
121
+ const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
122
+ if (now - decoded.t > maxAge || decoded.t - now > CONSENT_LINK_SKEW) {
123
+ return null
124
+ }
125
+ const referrer = referrerHostname()
126
+ if (referrer == null || !linker.domains.includes(referrer)) {
127
+ return null
128
+ }
126
129
 
127
- /**
128
- * The payload of the link this document was opened from, once it has passed every check that does
129
- * not depend on WHAT it carries: the parameter decodes and its version matches, its timestamp is
130
- * fresh (and not from the future beyond the allowed skew), and the request's referrer host is a
131
- * LISTED domain — read from `document.referrer`, never from the parameter itself. `null` otherwise.
132
- */
133
- const trustedPayload = (opts: ConsentOptions): ConsentLinkPayload | null => {
134
- const linker = opts.linker
135
- if (linker == null || typeof location === 'undefined') {
136
- return null
137
- }
138
- const raw = new URLSearchParams(location.search).get(linker.param ?? CONSENT_LINK_PARAM)
139
- if (raw == null) {
140
- return null
141
- }
142
- const decoded = decodeConsentLink(raw)
143
- if (decoded == null) {
144
- return null
145
- }
146
- const now = Math.floor(Date.now() / 1000)
147
- const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
148
- if (now - decoded.t > maxAge || decoded.t - now > CONSENT_LINK_SKEW) {
149
- return null
150
- }
151
- const referrer = referrerHostname()
152
- if (referrer == null || !linker.domains.includes(referrer)) {
153
- return null
130
+ return decoded
154
131
  }
155
132
 
156
- return decoded
157
- }
133
+ /** The entry of `supported` that `language` names — exactly, else by its base tag — or `null`. */
134
+ const supportedLanguage = (language: string, supported: string[]): string | null => {
135
+ const wanted = language.toLowerCase()
136
+ const exact = supported.find(candidate => candidate.toLowerCase() === wanted)
137
+ if (exact != null) {
138
+ return exact
139
+ }
140
+ const base = wanted.split(/[-_]/)[0]
158
141
 
159
- /** The entry of `supported` that `language` names — exactly, else by its base tag — or `null`. */
160
- const supportedLanguage = (language: string, supported: string[]): string | null => {
161
- const wanted = language.toLowerCase()
162
- const exact = supported.find(candidate => candidate.toLowerCase() === wanted)
163
- if (exact != null) {
164
- return exact
142
+ return supported.find(candidate => candidate.toLowerCase() === base) ?? null
165
143
  }
166
- const base = wanted.split(/[-_]/)[0]
167
144
 
168
- return supported.find(candidate => candidate.toLowerCase() === base) ?? null
169
- }
170
-
171
- /**
172
- * Persist an adopted language where the application's i18n layer reads its explicit choice from —
173
- * `linker.language.storageKey`, `owlmeans-lng` by default — and only while the visitor has GRANTED
174
- * `functional` on this document (a stored record, or the one adopted from the very link that
175
- * carried the language). Without that grant nothing is written and `false` comes back: remembering
176
- * a preference on a visitor's device is storage like any other, and a refusal ("reject all") is an
177
- * answer, not a gap to fill. The gate lives here, not in the callers, so no caller can forget it.
178
- *
179
- * It overwrites what is there on purpose: the carried language is the one the visitor was reading a
180
- * moment ago, which outranks a choice they made on this domain some other day. Storage that refuses
181
- * the write is not an error. Returns whether the language was written.
182
- */
183
- export const writeConsentLanguage = (language: string, opts?: ConsentOptions): boolean => {
184
- if (!functionalGranted(opts)) {
185
- return false
186
- }
187
- try {
188
- localStorage.setItem(opts?.linker?.language?.storageKey ?? CONSENT_LANGUAGE_KEY, language)
145
+ const writeConsentLanguage = (language: string, opts?: ConsentOptions): boolean => {
146
+ try {
147
+ localStorage.setItem(opts?.linker?.language?.storageKey ?? CONSENT_LANGUAGE_KEY, language)
189
148
 
190
- return true
191
- } catch {
192
- // Private modes and blocked storage both throw; the app simply keeps choosing its own language.
193
- return false
149
+ return true
150
+ } catch {
151
+ // Private modes and blocked storage both throw; the app simply keeps choosing its own language.
152
+ return false
153
+ }
194
154
  }
195
- }
196
155
 
197
- /** Escape `<` inside a JSON literal meant for an inline `<script>` — the same discipline every
198
- * caller of `consentBootstrapScript`/`consentGateScript` already owes the HTML it lands in, applied
199
- * here at the source since this fragment's dynamic values (a configured domain, a storage key) are
200
- * least likely to have been reviewed for it by whoever composes the final page. */
201
- const jsonForScript = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
202
-
203
- /**
204
- * Cross-domain consent: share this decision, and adopt one, between the domains named in
205
- * `opts.linker.domains`.
206
- *
207
- * `start` decorates outgoing first-party links; `adopt` reads and validates an incoming one;
208
- * `domains` discloses the list a dialog or policy page names. `decorate` is generic (a bare `URL`,
209
- * no DOM) so the same trust rule serves a real anchor click and a programmatic navigation
210
- * (`decorateConsentUrl`) alike — the `rel="noreferrer"` exemption is a DOM-only concept and is
211
- * therefore checked by `start`'s click handler, never here.
212
- */
213
- export const consentLinker = (): ConsentPlugin => {
214
- let installed = false
156
+ /** Escape `<` inside a JSON literal meant for an inline `<script>` — the same discipline every
157
+ * caller of `consentBootstrapScript`/`consentGateScript` already owes the HTML it lands in, applied
158
+ * here at the source since this fragment's dynamic values (a configured domain, a storage key) are
159
+ * least likely to have been reviewed for it by whoever composes the final page. */
160
+ const jsonForScript = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
215
161
 
216
- return {
217
- alias: 'linker',
162
+ const consentLinker = (): ConsentPlugin => {
163
+ let installed = false
218
164
 
219
- start: opts => {
220
- if (typeof document === 'undefined' || installed || opts.linker == null) {
221
- return
222
- }
223
- installed = true
165
+ return {
166
+ alias: 'linker',
224
167
 
225
- const onNavigate = (event: Event): void => {
226
- const target = event.target
227
- const anchor = target instanceof Element ? target.closest('a[href]') : null
228
- if (!(anchor instanceof HTMLAnchorElement)) {
229
- return
230
- }
231
- // The trust rule keeps `noreferrer` links untouched: a link that refuses to disclose the
232
- // referrer is refusing exactly the signal the receiving side's `adopt` needs to trust it.
233
- if (/\bnoreferrer\b/.test(anchor.getAttribute('rel') ?? '')) {
168
+ start: opts => {
169
+ if (typeof document === 'undefined' || installed || opts.linker == null) {
234
170
  return
235
171
  }
236
- // No decision yet is still worth a decoration when a language rides along with it.
237
- const record = readConsent(opts)
238
- if (record == null && opts.linker?.language == null) {
239
- return
172
+ installed = true
173
+
174
+ const onNavigate = (event: Event): void => {
175
+ const target = event.target
176
+ const anchor = target instanceof Element ? target.closest('a[href]') : null
177
+ if (!(anchor instanceof HTMLAnchorElement)) {
178
+ return
179
+ }
180
+ // The trust rule keeps `noreferrer` links untouched: a link that refuses to disclose the
181
+ // referrer is refusing exactly the signal the receiving side's `adopt` needs to trust it.
182
+ if (/\bnoreferrer\b/.test(anchor.getAttribute('rel') ?? '')) {
183
+ return
184
+ }
185
+ // No decision yet is still worth a decoration when a language rides along with it.
186
+ const record = explicitOf(consentStorageHelper.readConsent(opts))
187
+ if (record == null && opts.linker?.language == null) {
188
+ return
189
+ }
190
+ let url: URL
191
+ try {
192
+ url = new URL(anchor.href, location.href)
193
+ } catch {
194
+ return
195
+ }
196
+ const decorated = consentPluginHelper.decorateConsentUrl(url, record, opts)
197
+ if (decorated.toString() !== url.toString()) {
198
+ anchor.href = decorated.toString()
199
+ }
240
200
  }
241
- let url: URL
242
- try {
243
- url = new URL(anchor.href, location.href)
244
- } catch {
245
- return
201
+
202
+ // Capture phase: this must decide before the click's own navigation (or a framework router
203
+ // intercepting it) reads `href`. `auxclick`/`contextmenu` cover a middle-click and "copy link
204
+ // address" — an address copied undecorated is a link that silently fails to carry the choice.
205
+ document.addEventListener('click', onNavigate, true)
206
+ document.addEventListener('auxclick', onNavigate, true)
207
+ document.addEventListener('contextmenu', onNavigate, true)
208
+ },
209
+
210
+ decorate: (url, input, opts) => {
211
+ const record = explicitOf(input)
212
+ const linker = opts.linker
213
+ const host = typeof location !== 'undefined' ? location.hostname : ''
214
+ if (linker == null || url.hostname === host || !linker.domains.includes(url.hostname)) {
215
+ return null
246
216
  }
247
- const decorated = decorateConsentUrl(url, record, opts)
248
- if (decorated.toString() !== url.toString()) {
249
- anchor.href = decorated.toString()
217
+ // A link carries a decision, a language, or both; with neither there is nothing to say.
218
+ if (record == null && (linker.language == null || documentLanguage() == null)) {
219
+ return null
250
220
  }
251
- }
221
+ const next = new URL(url.toString())
222
+ next.searchParams.set(linker.param ?? CONSENT_LINK_PARAM, encodeConsentLink(record, opts))
252
223
 
253
- // Capture phase: this must decide before the click's own navigation (or a framework router
254
- // intercepting it) reads `href`. `auxclick`/`contextmenu` cover a middle-click and "copy link
255
- // address" — an address copied undecorated is a link that silently fails to carry the choice.
256
- document.addEventListener('click', onNavigate, true)
257
- document.addEventListener('auxclick', onNavigate, true)
258
- document.addEventListener('contextmenu', onNavigate, true)
259
- },
260
-
261
- decorate: (url, record, opts) => {
262
- const linker = opts.linker
263
- const host = typeof location !== 'undefined' ? location.hostname : ''
264
- if (linker == null || url.hostname === host || !linker.domains.includes(url.hostname)) {
265
- return null
266
- }
267
- // A link carries a decision, a language, or both; with neither there is nothing to say.
268
- if (record == null && (linker.language == null || documentLanguage() == null)) {
269
- return null
270
- }
271
- const next = new URL(url.toString())
272
- next.searchParams.set(linker.param ?? CONSENT_LINK_PARAM, encodeConsentLink(record, opts))
224
+ return next
225
+ },
273
226
 
274
- return next
275
- },
276
-
277
- adopt: opts => {
278
- const decoded = trustedPayload(opts)
279
- if (decoded == null) {
280
- return null
281
- }
282
- const optional = optionalOf(opts)
283
- if (!optional.every(category => decoded.c[category.key] === 0 || decoded.c[category.key] === 1)) {
284
- return null
285
- }
227
+ adopt: opts => {
228
+ const decoded = trustedPayload(opts)
229
+ if (decoded == null) {
230
+ return null
231
+ }
232
+ const optional = optionalOf(opts)
233
+ if (!optional.every(category => decoded.c[category.key] === 0 || decoded.c[category.key] === 1)) {
234
+ return null
235
+ }
286
236
 
287
- const record: ConsentRecord = { v: CONSENT_SCHEMA_VERSION }
288
- for (const category of categoriesOf(opts)) {
289
- record[category.key] = category.required === true || decoded.c[category.key] === 1
290
- }
237
+ const record: ConsentRecord = { v: CONSENT_SCHEMA_VERSION }
238
+ for (const category of categoriesOf(opts)) {
239
+ record[category.key] = category.required === true || decoded.c[category.key] === 1
240
+ }
291
241
 
292
- return record
293
- },
242
+ return record
243
+ },
294
244
 
295
- adoptLanguage: opts => {
296
- const supported = opts.linker?.language?.supported
297
- const decoded = supported != null ? trustedPayload(opts) : null
245
+ adoptLanguage: opts => {
246
+ const supported = opts.linker?.language?.supported
247
+ const decoded = supported != null ? trustedPayload(opts) : null
298
248
 
299
- return decoded?.l != null && supported != null ? supportedLanguage(decoded.l, supported) : null
300
- },
249
+ return decoded?.l != null && supported != null ? supportedLanguage(decoded.l, supported) : null
250
+ },
301
251
 
302
- domains: opts => opts.linker?.domains ?? [],
252
+ domains: opts => opts.linker?.domains ?? [],
253
+ }
303
254
  }
304
- }
305
255
 
306
- /**
307
- * Remove the link parameter from the visible URL, keeping every other parameter and the hash — the
308
- * real-TS equivalent of what {@link consentLinkerScript}'s embedded fragment already did inline.
309
- * Idempotent: a page with no head script calls this from `consentStore.init` and it strips once; a
310
- * page that ALSO carried the head script finds the parameter already gone and does nothing.
311
- */
312
- export const stripConsentLinkParam = (opts: ConsentOptions): void => {
313
- const linker = opts.linker
314
- if (linker == null || typeof location === 'undefined' || typeof history === 'undefined') {
315
- return
316
- }
317
- const param = linker.param ?? CONSENT_LINK_PARAM
318
- const url = new URL(location.href)
319
- if (!url.searchParams.has(param)) {
320
- return
256
+ const stripConsentLinkParam = (opts: ConsentOptions): void => {
257
+ const linker = opts.linker
258
+ if (linker == null || typeof location === 'undefined' || typeof history === 'undefined') {
259
+ return
260
+ }
261
+ const param = linker.param ?? CONSENT_LINK_PARAM
262
+ const url = new URL(location.href)
263
+ if (!url.searchParams.has(param)) {
264
+ return
265
+ }
266
+ url.searchParams.delete(param)
267
+ try {
268
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`)
269
+ } catch {
270
+ // A sandboxed/embedded context may refuse history writes; the parameter just stays visible.
271
+ }
321
272
  }
322
- url.searchParams.delete(param)
323
- try {
324
- history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`)
325
- } catch {
326
- // A sandboxed/embedded context may refuse history writes; the parameter just stays visible.
273
+
274
+ const consentLinkerScript = (opts: ConsentOptions): string => {
275
+ const linker = opts.linker
276
+ if (linker == null) {
277
+ return ''
278
+ }
279
+ const param = jsonForScript(linker.param ?? CONSENT_LINK_PARAM)
280
+ const domains = jsonForScript(linker.domains)
281
+ const storageKey = jsonForScript(opts.storageKey ?? CONSENT_KEY)
282
+ const optionalKeys = jsonForScript(optionalOf(opts).map(category => category.key))
283
+ const allCategories = jsonForScript(
284
+ categoriesOf(opts).map(category => ({ key: category.key, required: category.required === true }))
285
+ )
286
+ const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
287
+ const skew = CONSENT_LINK_SKEW
288
+ const cookieDays = opts.cookieDays ?? CONSENT_COOKIE_DAYS
289
+ const version = CONSENT_LINK_VERSION
290
+ // The language rides the same trust decision and is written unconditionally — it is strictly
291
+ // necessary storage, whatever the cookie decision is. Mirrors `supportedLanguage` +
292
+ // `writeConsentLanguage`; empty for a page that does not receive a language.
293
+ const language = linker.language?.supported != null
294
+ ? `var L=${jsonForScript(linker.language.supported)};` +
295
+ `if(typeof j.l==='string'){var lc=j.l.toLowerCase(),lm=null,q;` +
296
+ `for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}` +
297
+ `if(!lm){lc=lc.split(/[-_]/)[0];for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}}` +
298
+ `if(lm){try{w.localStorage.setItem(${jsonForScript(linker.language.storageKey ?? CONSENT_LANGUAGE_KEY)},lm)}catch(e){}}}`
299
+ : ''
300
+
301
+ return `(function(w,d){` +
302
+ `var p=${param};var qs;try{qs=new URLSearchParams(location.search)}catch(e){return}` +
303
+ `var raw=qs.get(p);if(raw==null)return;` +
304
+ `qs.delete(p);var rest=qs.toString();` +
305
+ `var url=location.pathname+(rest?('?'+rest):'')+location.hash;` +
306
+ `try{history.replaceState(history.state,'',url)}catch(e){}` +
307
+ `try{` +
308
+ `var b=raw.replace(/-/g,'+').replace(/_/g,'/');while(b.length%4)b+='=';` +
309
+ `var j=JSON.parse(atob(b));` +
310
+ `if(!j||j.v!==${version}||typeof j.t!=='number'||!j.c||typeof j.c!=='object')return;` +
311
+ `var now=Math.floor(Date.now()/1000);` +
312
+ `if(now-j.t>${maxAge}||j.t-now>${skew})return;` +
313
+ `var ref=null;try{ref=new URL(d.referrer).hostname}catch(e){}` +
314
+ `if(!ref||${domains}.indexOf(ref)<0)return;` +
315
+ `var existing=null;try{existing=w.localStorage.getItem(${storageKey})}catch(e){}` +
316
+ `if(!existing){var cp=('; '+d.cookie).split('; '+${storageKey}+'=');` +
317
+ `if(cp.length===2)existing=cp.pop().split(';').shift()}` +
318
+ // An automatic decision is replaced by the visitor's own; an unparseable one still blocks.
319
+ `var ex=null;if(existing){try{ex=JSON.parse(existing)}catch(e){ex=false}}` +
320
+ `if(!existing||(ex&&typeof ex.auto==='number')){` +
321
+ `var keys=${optionalKeys},ok=true;` +
322
+ `for(var i=0;i<keys.length;i++){if(j.c[keys[i]]!==0&&j.c[keys[i]]!==1){ok=false;break}}` +
323
+ `if(ok){` +
324
+ `var cats=${allCategories},rec={v:${version}};` +
325
+ `for(var k=0;k<cats.length;k++){var c=cats[k];rec[c.key]=c.required||j.c[c.key]===1}` +
326
+ `var val=JSON.stringify(rec);` +
327
+ `try{w.localStorage.setItem(${storageKey},val)}catch(e){}` +
328
+ `var exp=new Date(Date.now()+${cookieDays}*86400000).toUTCString();` +
329
+ `d.cookie=${storageKey}+'='+val+';expires='+exp+';path=/;SameSite=Lax'` +
330
+ `}` +
331
+ `}` +
332
+ language +
333
+ `}catch(e){}` +
334
+ `})(window,document)`
327
335
  }
328
- }
329
336
 
330
- /**
331
- * The inline `<head>` fragment that adopts a decorated link's decision and strips the parameter —
332
- * standalone, so a page can stamp it even where nothing else here runs (a legal page with no tag
333
- * script). `@owlmeans/web-gtm`'s `consentBootstrapScript` embeds this SAME fragment right after
334
- * pushing Consent Mode's defaults, so a page that has both never runs it twice with different
335
- * effect — the second attempt finds the parameter already gone and does nothing.
336
- *
337
- * Mirrors `consentLinker().adopt` exactly (v2, referrer + freshness/skew + full optional coverage +
338
- * no existing record), but as hand-rolled JS text rather than a call into this module — the same
339
- * discipline `consentBootstrapScript`/`consentGateScript` already follow, because this has to run
340
- * before any bundle (this module included) has loaded. The parameter is ALWAYS stripped via
341
- * `history.replaceState`, whether or not the trust rule accepts it: a stale or foreign `owlcc` is
342
- * exactly as much noise as an adopted one, and neither belongs in the visible URL or in what a page
343
- * reload, a shared link or a browser history entry carries forward.
344
- */
345
- export const consentLinkerScript = (opts: ConsentOptions): string => {
346
- const linker = opts.linker
347
- if (linker == null) {
348
- return ''
337
+ return {
338
+ encodeConsentLink, decodeConsentLink, writeConsentLanguage, consentLinker, stripConsentLinkParam,
339
+ consentLinkerScript,
349
340
  }
350
- const param = jsonForScript(linker.param ?? CONSENT_LINK_PARAM)
351
- const domains = jsonForScript(linker.domains)
352
- const storageKey = jsonForScript(opts.storageKey ?? CONSENT_KEY)
353
- const optionalKeys = jsonForScript(optionalOf(opts).map(category => category.key))
354
- const allCategories = jsonForScript(
355
- categoriesOf(opts).map(category => ({ key: category.key, required: category.required === true }))
356
- )
357
- const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
358
- const skew = CONSENT_LINK_SKEW
359
- const cookieDays = opts.cookieDays ?? CONSENT_COOKIE_DAYS
360
- const version = CONSENT_LINK_VERSION
361
- // The language rides the same trust decision but is WRITTEN only while `functional` is granted
362
- // here — in the stored record, or in the record just adopted from this very link (`fg` below).
363
- // Otherwise it is left on `window` for the store to pick up (memory, never storage): the visitor
364
- // may still grant functional storage later in this page's life. Mirrors `supportedLanguage` +
365
- // `writeConsentLanguage`; empty for a page that does not receive a language.
366
- const functionalKey = jsonForScript(CONSENT_FUNCTIONAL)
367
- const language = linker.language?.supported != null
368
- ? `var L=${jsonForScript(linker.language.supported)};` +
369
- `if(typeof j.l==='string'){var lc=j.l.toLowerCase(),lm=null,q;` +
370
- `for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}` +
371
- `if(!lm){lc=lc.split(/[-_]/)[0];for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}}` +
372
- `if(lm){if(fg){try{w.localStorage.setItem(${jsonForScript(linker.language.storageKey ?? CONSENT_LANGUAGE_KEY)},lm)}catch(e){}}` +
373
- `else{w[${jsonForScript(CONSENT_PENDING_LANGUAGE)}]=lm}}}`
374
- : ''
375
-
376
- return `(function(w,d){` +
377
- `var p=${param};var qs;try{qs=new URLSearchParams(location.search)}catch(e){return}` +
378
- `var raw=qs.get(p);if(raw==null)return;` +
379
- `qs.delete(p);var rest=qs.toString();` +
380
- `var url=location.pathname+(rest?('?'+rest):'')+location.hash;` +
381
- `try{history.replaceState(history.state,'',url)}catch(e){}` +
382
- `try{` +
383
- `var b=raw.replace(/-/g,'+').replace(/_/g,'/');while(b.length%4)b+='=';` +
384
- `var j=JSON.parse(atob(b));` +
385
- `if(!j||j.v!==${version}||typeof j.t!=='number'||!j.c||typeof j.c!=='object')return;` +
386
- `var now=Math.floor(Date.now()/1000);` +
387
- `if(now-j.t>${maxAge}||j.t-now>${skew})return;` +
388
- `var ref=null;try{ref=new URL(d.referrer).hostname}catch(e){}` +
389
- `if(!ref||${domains}.indexOf(ref)<0)return;` +
390
- `var existing=null;try{existing=w.localStorage.getItem(${storageKey})}catch(e){}` +
391
- `if(!existing){var cp=('; '+d.cookie).split('; '+${storageKey}+'=');` +
392
- `if(cp.length===2)existing=cp.pop().split(';').shift()}` +
393
- // `fg`: functional storage is granted here — by a parseable stored record, or by the one
394
- // adopted below. A record with no `functional` key (saved before the category existed) is not
395
- // a grant.
396
- `var fg=false;` +
397
- `if(existing){try{var ex=JSON.parse(existing);fg=!!ex&&typeof ex==='object'&&ex[${functionalKey}]===true}catch(e){}}` +
398
- `else{` +
399
- `var keys=${optionalKeys},ok=true;` +
400
- `for(var i=0;i<keys.length;i++){if(j.c[keys[i]]!==0&&j.c[keys[i]]!==1){ok=false;break}}` +
401
- `if(ok){` +
402
- `var cats=${allCategories},rec={v:${version}};` +
403
- `for(var k=0;k<cats.length;k++){var c=cats[k];rec[c.key]=c.required||j.c[c.key]===1}` +
404
- `var val=JSON.stringify(rec);` +
405
- `try{w.localStorage.setItem(${storageKey},val)}catch(e){}` +
406
- `var exp=new Date(Date.now()+${cookieDays}*86400000).toUTCString();` +
407
- `d.cookie=${storageKey}+'='+val+';expires='+exp+';path=/;SameSite=Lax';fg=rec[${functionalKey}]===true` +
408
- `}` +
409
- `}` +
410
- language +
411
- `}catch(e){}` +
412
- `})(window,document)`
413
341
  }
342
+
343
+ export const consentLinkHelper = createConsentLinkHelper()
344
+
345
+ /** @deprecated compat:factory-refactor — use `consentLinkHelper.encodeConsentLink(…)` */
346
+ export const encodeConsentLink = (record: ConsentRecord | null, opts?: ConsentOptions): string =>
347
+ consentLinkHelper.encodeConsentLink(record, opts)
348
+
349
+ /** @deprecated compat:factory-refactor — use `consentLinkHelper.consentLinkerScript(…)` */
350
+ export const consentLinkerScript = (opts: ConsentOptions): string => consentLinkHelper.consentLinkerScript(opts)