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