@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
@@ -0,0 +1,42 @@
1
+ import type { ConsentGeoLocation, ConsentOptions, ConsentPlugin, ConsentRecord } from '../types.js'
2
+
3
+ /** The document's consent-plugin registry and the hooks run over every registered plugin. */
4
+ export interface ConsentPluginHelper {
5
+ /** Register a plugin. Replace-by-alias; sorted by priority (higher first) on every read. */
6
+ registerConsentPlugin: (plugin: ConsentPlugin) => void
7
+ /** Remove a plugin by alias — for a test, or an application tearing a locator down. */
8
+ unregisterConsentPlugin: (alias: string) => void
9
+ /** Every registered plugin, priority-sorted (higher first, ties keep insertion order). */
10
+ consentPlugins: () => ConsentPlugin[]
11
+ /**
12
+ * Ask every registered plugin to decorate `url` with this document's decision, in priority order,
13
+ * stopping at the first one that actually changes it. A plugin whose own rules refuse (a foreign
14
+ * host, a `noreferrer` link, no local decision yet) returns `null` and is skipped, not an error.
15
+ */
16
+ decorateConsentUrl: (url: string | URL, record: ConsentRecord | null, opts: ConsentOptions) => URL
17
+ /**
18
+ * Every domain this document's decision is disclosed to, current host first, deduplicated — what a
19
+ * dialog or a policy page lists. A plugin that runs on this host but names no domains of its own
20
+ * (nothing registered, or `domains` not implemented) leaves the list at just the current host,
21
+ * which is always correct to show even with no plugin at all.
22
+ */
23
+ consentDomains: (opts: ConsentOptions) => string[]
24
+ /** Try every registered plugin's `adopt`, in priority order, and use the first decision offered. */
25
+ adoptConsent: (opts: ConsentOptions) => ConsentRecord | null
26
+ /**
27
+ * Try every registered plugin's `adoptLanguage`, in priority order, and use the first language
28
+ * offered. The caller persists it (`writeConsentLanguage`) — and has to do that BEFORE its i18n
29
+ * layer reads storage, which is why the inline `<head>` fragment (`consentLinkerScript`) is the
30
+ * one that normally does.
31
+ */
32
+ adoptConsentLanguage: (opts: ConsentOptions) => string | null
33
+ /** Run every registered plugin's `start`, in priority order. `consentStore.init` calls this once. */
34
+ startConsentPlugins: (opts: ConsentOptions) => void
35
+ /**
36
+ * Ask every registered plugin's `locate`, in priority order, for the visitor's country: the first
37
+ * usable ISO alpha-2 code wins (upper-cased). A throw, or a code that names no country
38
+ * (`CONSENT_GEO_UNKNOWN`, anything malformed), hands over to the next locator; with none left it
39
+ * rejects with the last failure. Timeouts are the caller's (`consentGeoHelper.decide`).
40
+ */
41
+ locateConsent: (opts: ConsentOptions) => Promise<ConsentGeoLocation>
42
+ }
package/src/plugins.ts CHANGED
@@ -1,46 +1,7 @@
1
- import type { ConsentOptions, ConsentRecord } from './types.js'
2
-
3
- /**
4
- * An extension seam for `@owlmeans/consent`: something that reacts to this document's consent
5
- * decision, or supplies one from elsewhere, without the core store knowing anything about where
6
- * that decision came from.
7
- *
8
- * `consentLinker` (`./linker.js`) is the one built-in plugin — cross-domain consent through
9
- * decorated links — but nothing here is specific to it. A registry rather than a fixed option on
10
- * `ConsentOptions` because the store has zero dependencies today and stays that way: a plugin is
11
- * opt-in code an application imports for its own reason, never a bundled feature this package
12
- * pulls in whether or not anything uses it.
13
- */
14
- export interface ConsentPlugin {
15
- /** Stable identity. Registering the same alias again REPLACES the earlier plugin. */
16
- alias: string
17
- /** Run order when more than one plugin implements the same hook. Higher first. Defaults to 0. */
18
- priority?: number
19
- /** Installed once, the first time `consentStore.init` runs with this plugin registered. */
20
- start?: (opts: ConsentOptions) => void
21
- /**
22
- * Offer a decision from outside this document's own storage — e.g. one carried on the URL that
23
- * led here. Returns a full record to adopt, or `null` to defer to the next plugin / the ordinary
24
- * "ask" path. Never called once a stored record already exists.
25
- */
26
- adopt?: (opts: ConsentOptions) => ConsentRecord | null
27
- /**
28
- * Offer an interface language from outside this document's own storage — e.g. the one the page
29
- * that led here was read in. Returns a language the application supports, or `null` to keep
30
- * choosing its own. Only a CANDIDATE: it says what the link carried, and persisting it is
31
- * `writeConsentLanguage`'s call, which refuses until `functional` is granted on this document.
32
- */
33
- adoptLanguage?: (opts: ConsentOptions) => string | null
34
- /**
35
- * Rewrite an outgoing URL to carry this document's current decision — and its language — or
36
- * `null` to leave it untouched (a foreign host, a link that opts out, nothing to carry yet).
37
- * `record` is `null` while the visitor has not decided: a plugin that also carries something
38
- * else (the language) still has a reason to decorate then.
39
- */
40
- decorate?: (url: URL, record: ConsentRecord | null, opts: ConsentOptions) => URL | null
41
- /** Extra domains this decision is understood to apply to, for disclosure — never deduplicated here. */
42
- domains?: (opts: ConsentOptions) => string[]
43
- }
1
+ import { CONSENT_GEO_UNKNOWN } from './consts.js'
2
+ import { COUNTRY_CODE } from './consts.local.js'
3
+ import type { ConsentGeoLocation, ConsentOptions, ConsentRecord, ConsentPlugin } from './types.js'
4
+ import type { ConsentPluginHelper } from './plugins/types.js'
44
5
 
45
6
  /**
46
7
  * Module-global by design, like `@owlmeans/client-auth/login`'s method/step registries: consent is
@@ -49,83 +10,98 @@ export interface ConsentPlugin {
49
10
  */
50
11
  const plugins = new Map<string, ConsentPlugin>()
51
12
 
52
- /** Register a plugin. Replace-by-alias; sorted by priority (higher first) on every read. */
53
- export const registerConsentPlugin = (plugin: ConsentPlugin): void => {
54
- plugins.set(plugin.alias, plugin)
55
- }
13
+ export const createConsentPluginHelper = (): ConsentPluginHelper => {
14
+ const registerConsentPlugin = (plugin: ConsentPlugin): void => {
15
+ plugins.set(plugin.alias, plugin)
16
+ }
56
17
 
57
- /** Every registered plugin, priority-sorted (higher first, ties keep insertion order). */
58
- export const consentPlugins = (): ConsentPlugin[] =>
59
- [...plugins.values()].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
18
+ const unregisterConsentPlugin = (alias: string): void => {
19
+ plugins.delete(alias)
20
+ }
60
21
 
61
- /**
62
- * Ask every registered plugin to decorate `url` with this document's decision, in priority order,
63
- * stopping at the first one that actually changes it. A plugin whose own rules refuse (a foreign
64
- * host, a `noreferrer` link, no local decision yet) returns `null` and is skipped, not an error.
65
- */
66
- export const decorateConsentUrl = (
67
- url: string | URL, record: ConsentRecord | null, opts: ConsentOptions
68
- ): URL => {
69
- let result = url instanceof URL ? url : new URL(url)
70
- for (const plugin of consentPlugins()) {
71
- if (plugin.decorate == null) {
72
- continue
73
- }
74
- const decorated = plugin.decorate(result, record, opts)
75
- if (decorated != null) {
76
- result = decorated
22
+ const consentPlugins = (): ConsentPlugin[] =>
23
+ [...plugins.values()].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
24
+
25
+ const decorateConsentUrl = (
26
+ url: string | URL, record: ConsentRecord | null, opts: ConsentOptions
27
+ ): URL => {
28
+ let result = url instanceof URL ? url : new URL(url)
29
+ for (const plugin of consentPlugins()) {
30
+ if (plugin.decorate == null) {
31
+ continue
32
+ }
33
+ const decorated = plugin.decorate(result, record, opts)
34
+ if (decorated != null) {
35
+ result = decorated
36
+ }
77
37
  }
78
- }
79
38
 
80
- return result
81
- }
39
+ return result
40
+ }
82
41
 
83
- /**
84
- * Every domain this document's decision is disclosed to, current host first, deduplicated — what a
85
- * dialog or a policy page lists. A plugin that runs on this host but names no domains of its own
86
- * (nothing registered, or `domains` not implemented) leaves the list at just the current host,
87
- * which is always correct to show even with no plugin at all.
88
- */
89
- export const consentDomains = (opts: ConsentOptions): string[] => {
90
- const host = typeof location !== 'undefined' ? location.hostname : ''
91
- const named = consentPlugins().flatMap(plugin => plugin.domains?.(opts) ?? [])
42
+ const consentDomains = (opts: ConsentOptions): string[] => {
43
+ const host = typeof location !== 'undefined' ? location.hostname : ''
44
+ const named = consentPlugins().flatMap(plugin => plugin.domains?.(opts) ?? [])
92
45
 
93
- return [...new Set([host, ...named].filter(domain => domain !== ''))]
94
- }
46
+ return [...new Set([host, ...named].filter(domain => domain !== ''))]
47
+ }
95
48
 
96
- /** Try every registered plugin's `adopt`, in priority order, and use the first decision offered. */
97
- export const adoptConsent = (opts: ConsentOptions): ConsentRecord | null => {
98
- for (const plugin of consentPlugins()) {
99
- const adopted = plugin.adopt?.(opts)
100
- if (adopted != null) {
101
- return adopted
49
+ const adoptConsent = (opts: ConsentOptions): ConsentRecord | null => {
50
+ for (const plugin of consentPlugins()) {
51
+ const adopted = plugin.adopt?.(opts)
52
+ if (adopted != null) {
53
+ return adopted
54
+ }
102
55
  }
56
+
57
+ return null
103
58
  }
104
59
 
105
- return null
106
- }
60
+ const adoptConsentLanguage = (opts: ConsentOptions): string | null => {
61
+ for (const plugin of consentPlugins()) {
62
+ const adopted = plugin.adoptLanguage?.(opts)
63
+ if (adopted != null) {
64
+ return adopted
65
+ }
66
+ }
107
67
 
108
- /**
109
- * Try every registered plugin's `adoptLanguage`, in priority order, and use the first language
110
- * offered. The caller persists it (`writeConsentLanguage`, which refuses until `functional` is
111
- * granted) — and has to do that BEFORE its i18n
112
- * layer reads storage, which is why the inline `<head>` fragment (`consentLinkerScript`) is the
113
- * one that normally does.
114
- */
115
- export const adoptConsentLanguage = (opts: ConsentOptions): string | null => {
116
- for (const plugin of consentPlugins()) {
117
- const adopted = plugin.adoptLanguage?.(opts)
118
- if (adopted != null) {
119
- return adopted
68
+ return null
69
+ }
70
+
71
+ const startConsentPlugins = (opts: ConsentOptions): void => {
72
+ for (const plugin of consentPlugins()) {
73
+ plugin.start?.(opts)
120
74
  }
121
75
  }
122
76
 
123
- return null
124
- }
77
+ const locateConsent = async (opts: ConsentOptions): Promise<ConsentGeoLocation> => {
78
+ let failure: unknown = new Error('consent:geo:no-locator')
79
+ for (const plugin of consentPlugins()) {
80
+ if (plugin.locate == null) {
81
+ continue
82
+ }
83
+ try {
84
+ const country = (await plugin.locate(opts))?.country?.trim().toUpperCase() ?? ''
85
+ if (COUNTRY_CODE.test(country) && !CONSENT_GEO_UNKNOWN.includes(country)) {
86
+ return { country }
87
+ }
88
+ failure = new Error(`consent:geo:unusable-country:${plugin.alias}`)
89
+ } catch (error) {
90
+ failure = error
91
+ }
92
+ }
125
93
 
126
- /** Run every registered plugin's `start`, in priority order. `consentStore.init` calls this once. */
127
- export const startConsentPlugins = (opts: ConsentOptions): void => {
128
- for (const plugin of consentPlugins()) {
129
- plugin.start?.(opts)
94
+ throw failure
95
+ }
96
+
97
+ return {
98
+ registerConsentPlugin, unregisterConsentPlugin, consentPlugins, decorateConsentUrl,
99
+ consentDomains, adoptConsent, adoptConsentLanguage, startConsentPlugins, locateConsent,
130
100
  }
131
101
  }
102
+
103
+ export const consentPluginHelper = createConsentPluginHelper()
104
+
105
+ /** @deprecated compat:factory-refactor — use `consentPluginHelper.decorateConsentUrl(…)` */
106
+ export const decorateConsentUrl = (url: string | URL, record: ConsentRecord | null, opts: ConsentOptions): URL =>
107
+ consentPluginHelper.decorateConsentUrl(url, record, opts)
@@ -0,0 +1,31 @@
1
+ import type { ConsentOptions, ConsentRecord } from '../types.js'
2
+
3
+ /** Where a visitor's choice is kept: localStorage first, the cookie second. */
4
+ export interface ConsentStorageHelper {
5
+ /**
6
+ * Bring a stored record up to the current shape.
7
+ *
8
+ * A record with no `v` was written before the essential category existed, when it was implicitly
9
+ * always granted. The visitor DID choose; upgrading in place honours that choice, where treating
10
+ * the record as unusable would re-prompt everyone who has ever visited.
11
+ */
12
+ migrateConsent: (raw: ConsentRecord | null) => ConsentRecord | null
13
+ /**
14
+ * What this browser has already chosen, or null.
15
+ *
16
+ * localStorage first and the cookie second, because that is the order the widget this generalises
17
+ * used and the two can disagree: a visitor who cleared site data but kept cookies still has an
18
+ * answer, and asking them again would be wrong.
19
+ */
20
+ readConsent: (opts?: ConsentOptions) => ConsentRecord | null
21
+ /**
22
+ * Record the choice in both places.
23
+ *
24
+ * Two stores rather than one because neither is reliable alone: localStorage is cleared by "clear
25
+ * site data" while the cookie survives it, and the cookie is refused where third-party storage is
26
+ * blocked while localStorage may not be.
27
+ */
28
+ writeConsent: (record: ConsentRecord, opts?: ConsentOptions) => void
29
+ /** Forget the choice in both places. */
30
+ clearConsent: (opts?: ConsentOptions) => void
31
+ }
package/src/storage.ts CHANGED
@@ -2,95 +2,93 @@ import {
2
2
  CONSENT_COOKIE_DAYS, CONSENT_ESSENTIAL, CONSENT_KEY, CONSENT_SCHEMA_VERSION,
3
3
  } from './consts.js'
4
4
  import type { ConsentOptions, ConsentRecord } from './types.js'
5
+ import type { ConsentStorageHelper } from './storage/types.js'
5
6
 
6
- const key = (opts?: ConsentOptions): string => opts?.storageKey ?? CONSENT_KEY
7
+ export const createConsentStorageHelper = (): ConsentStorageHelper => {
8
+ const key = (opts?: ConsentOptions): string => opts?.storageKey ?? CONSENT_KEY
7
9
 
8
- const parse = (raw: string | null | undefined): ConsentRecord | null => {
9
- if (raw == null || raw === '') {
10
- return null
11
- }
12
- try {
13
- const value = JSON.parse(raw) as unknown
10
+ const parse = (raw: string | null | undefined): ConsentRecord | null => {
11
+ if (raw == null || raw === '') {
12
+ return null
13
+ }
14
+ try {
15
+ const value = JSON.parse(raw) as unknown
14
16
 
15
- return value != null && typeof value === 'object' ? value as ConsentRecord : null
16
- } catch {
17
- return null
17
+ return value != null && typeof value === 'object' ? value as ConsentRecord : null
18
+ } catch {
19
+ return null
20
+ }
18
21
  }
19
- }
20
22
 
21
- /**
22
- * Bring a stored record up to the current shape.
23
- *
24
- * A record with no `v` was written before the essential category existed, when it was implicitly
25
- * always granted. The visitor DID choose; upgrading in place honours that choice, where treating
26
- * the record as unusable would re-prompt everyone who has ever visited.
27
- */
28
- export const migrateConsent = (raw: ConsentRecord | null): ConsentRecord | null =>
29
- raw == null ? null
30
- : raw.v === CONSENT_SCHEMA_VERSION ? raw
31
- : { ...raw, [CONSENT_ESSENTIAL]: true, v: CONSENT_SCHEMA_VERSION }
32
-
33
- const fromCookie = (name: string): string | null => {
34
- if (typeof document === 'undefined') {
35
- return null
23
+ const migrateConsent = (raw: ConsentRecord | null): ConsentRecord | null =>
24
+ raw == null ? null
25
+ : raw.v === CONSENT_SCHEMA_VERSION ? raw
26
+ : { ...raw, [CONSENT_ESSENTIAL]: true, v: CONSENT_SCHEMA_VERSION }
27
+
28
+ const fromCookie = (name: string): string | null => {
29
+ if (typeof document === 'undefined') {
30
+ return null
31
+ }
32
+ const parts = `; ${document.cookie}`.split(`; ${name}=`)
33
+
34
+ return parts.length === 2 ? parts.pop()?.split(';').shift() ?? null : null
36
35
  }
37
- const parts = `; ${document.cookie}`.split(`; ${name}=`)
38
36
 
39
- return parts.length === 2 ? parts.pop()?.split(';').shift() ?? null : null
40
- }
37
+ const readConsent = (opts?: ConsentOptions): ConsentRecord | null => {
38
+ const name = key(opts)
39
+ let raw: string | null = null
40
+ try {
41
+ raw = typeof localStorage !== 'undefined' ? localStorage.getItem(name) : null
42
+ } catch { /* private modes and blocked storage both throw; the cookie may still answer */ }
41
43
 
42
- /**
43
- * What this browser has already chosen, or null.
44
- *
45
- * localStorage first and the cookie second, because that is the order the widget this generalises
46
- * used and the two can disagree: a visitor who cleared site data but kept cookies still has an
47
- * answer, and asking them again would be wrong.
48
- */
49
- export const readConsent = (opts?: ConsentOptions): ConsentRecord | null => {
50
- const name = key(opts)
51
- let raw: string | null = null
52
- try {
53
- raw = typeof localStorage !== 'undefined' ? localStorage.getItem(name) : null
54
- } catch { /* private modes and blocked storage both throw; the cookie may still answer */ }
55
-
56
- return migrateConsent(parse(raw) ?? parse(fromCookie(name)))
57
- }
44
+ return migrateConsent(parse(raw) ?? parse(fromCookie(name)))
45
+ }
58
46
 
59
- /**
60
- * Record the choice in both places.
61
- *
62
- * Two stores rather than one because neither is reliable alone: localStorage is cleared by "clear
63
- * site data" while the cookie survives it, and the cookie is refused where third-party storage is
64
- * blocked while localStorage may not be.
65
- */
66
- export const writeConsent = (record: ConsentRecord, opts?: ConsentOptions): void => {
67
- const name = key(opts)
68
- const value = JSON.stringify({ ...record, v: CONSENT_SCHEMA_VERSION })
69
- try {
70
- if (typeof localStorage !== 'undefined') {
71
- localStorage.setItem(name, value)
72
- }
73
- } catch { /* the cookie below is the fallback */ }
47
+ const writeConsent = (record: ConsentRecord, opts?: ConsentOptions): void => {
48
+ const name = key(opts)
49
+ const value = JSON.stringify({ ...record, v: CONSENT_SCHEMA_VERSION })
50
+ try {
51
+ if (typeof localStorage !== 'undefined') {
52
+ localStorage.setItem(name, value)
53
+ }
54
+ } catch { /* the cookie below is the fallback */ }
74
55
 
75
- if (typeof document === 'undefined') {
76
- return
56
+ if (typeof document === 'undefined') {
57
+ return
58
+ }
59
+ const days = opts?.cookieDays ?? CONSENT_COOKIE_DAYS
60
+ const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()
61
+ const domain = opts?.cookieDomain != null ? `;domain=${opts.cookieDomain}` : ''
62
+ // `SameSite=Lax` is stated rather than left to the browser default, which differs between them.
63
+ // This cookie is never sent cross-site on purpose — it records a preference, not a session.
64
+ document.cookie = `${name}=${value};expires=${expires};path=/;SameSite=Lax${domain}`
77
65
  }
78
- const days = opts?.cookieDays ?? CONSENT_COOKIE_DAYS
79
- const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()
80
- const domain = opts?.cookieDomain != null ? `;domain=${opts.cookieDomain}` : ''
81
- // `SameSite=Lax` is stated rather than left to the browser default, which differs between them.
82
- // This cookie is never sent cross-site on purpose — it records a preference, not a session.
83
- document.cookie = `${name}=${value};expires=${expires};path=/;SameSite=Lax${domain}`
84
- }
85
66
 
86
- export const clearConsent = (opts?: ConsentOptions): void => {
87
- const name = key(opts)
88
- try {
89
- if (typeof localStorage !== 'undefined') {
90
- localStorage.removeItem(name)
67
+ const clearConsent = (opts?: ConsentOptions): void => {
68
+ const name = key(opts)
69
+ try {
70
+ if (typeof localStorage !== 'undefined') {
71
+ localStorage.removeItem(name)
72
+ }
73
+ } catch { /* nothing to remove if storage was never available */ }
74
+ if (typeof document !== 'undefined') {
75
+ // The same `domain` the write used — a cookie is only replaced by one that names its domain.
76
+ const domain = opts?.cookieDomain != null ? `;domain=${opts.cookieDomain}` : ''
77
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/${domain}`
91
78
  }
92
- } catch { /* nothing to remove if storage was never available */ }
93
- if (typeof document !== 'undefined') {
94
- document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`
95
79
  }
80
+
81
+ return { migrateConsent, readConsent, writeConsent, clearConsent }
96
82
  }
83
+
84
+ export const consentStorageHelper = createConsentStorageHelper()
85
+
86
+ /** @deprecated compat:factory-refactor — use `consentStorageHelper.readConsent(…)` */
87
+ export const readConsent = (opts?: ConsentOptions): ConsentRecord | null => consentStorageHelper.readConsent(opts)
88
+
89
+ /** @deprecated compat:factory-refactor — use `consentStorageHelper.writeConsent(…)` */
90
+ export const writeConsent = (record: ConsentRecord, opts?: ConsentOptions): void =>
91
+ consentStorageHelper.writeConsent(record, opts)
92
+
93
+ /** @deprecated compat:factory-refactor — use `consentStorageHelper.clearConsent(…)` */
94
+ export const clearConsent = (opts?: ConsentOptions): void => consentStorageHelper.clearConsent(opts)