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