@consenti/ui 0.1.0
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.
- package/README.md +888 -0
- package/dist/angular.d.cts +481 -0
- package/dist/angular.d.ts +481 -0
- package/dist/angular.js +74 -0
- package/dist/angular.js.map +1 -0
- package/dist/angular.mjs +53 -0
- package/dist/angular.mjs.map +1 -0
- package/dist/chunk-ARALKXMB.mjs +20 -0
- package/dist/chunk-ARALKXMB.mjs.map +1 -0
- package/dist/hmac-N6H7F2K2.mjs +26 -0
- package/dist/hmac-N6H7F2K2.mjs.map +1 -0
- package/dist/http-VHOGZ6ZJ.mjs +7 -0
- package/dist/http-VHOGZ6ZJ.mjs.map +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +770 -0
- package/dist/index.mjs +2221 -0
- package/dist/index.mjs.map +1 -0
- package/dist/index.umd.js +2305 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/react.d.cts +452 -0
- package/dist/react.d.ts +452 -0
- package/dist/react.js +61 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +40 -0
- package/dist/react.mjs.map +1 -0
- package/dist/style.d.ts +2 -0
- package/dist/testing.d.cts +109 -0
- package/dist/testing.d.ts +109 -0
- package/dist/testing.js +95 -0
- package/dist/testing.js.map +1 -0
- package/dist/testing.mjs +74 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/vue.d.cts +446 -0
- package/dist/vue.d.ts +446 -0
- package/dist/vue.js +73 -0
- package/dist/vue.js.map +1 -0
- package/dist/vue.mjs +52 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +97 -0
package/README.md
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
# @consenti/ui
|
|
2
|
+
|
|
3
|
+
Zero-dependency, GDPR-compliant cookie consent widget for any web framework.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@consenti/ui)
|
|
6
|
+
[](../../LICENSE)
|
|
7
|
+
[](https://caniuse.com)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why this package?
|
|
12
|
+
|
|
13
|
+
- **Zero runtime dependencies** — browser built-ins only (`fetch`, `crypto.subtle`, `BroadcastChannel`, `CustomEvent`, `document.cookie`, `localStorage`)
|
|
14
|
+
- **Fully standalone or API-connected** — works offline with a local profile, or connects to `@consenti/api` for dashboard-managed profiles
|
|
15
|
+
- **Multi-regulation** — GDPR, UK-GDPR, CCPA, CPRA, LGPD, DPDPA, PIPEDA, POPIA, PDPA-TH, APPI, KVKK
|
|
16
|
+
- **GPC aware** — Global Privacy Control signal detection with `true` / `'strict'` modes
|
|
17
|
+
- **GTM / Google Consent Mode v2** — built-in `dataLayer` integration
|
|
18
|
+
- **SSR-safe** — all browser API access is guarded; `new ConsentiSetup()` during SSR is a silent no-op
|
|
19
|
+
- **Fully typed** — ships TypeScript definitions; no `@types` needed
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Browser Support
|
|
24
|
+
|
|
25
|
+
ES2020+ · Chrome 80+ · Firefox 74+ · Safari 13.1+
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### npm (recommended)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install @consenti/ui
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then import CSS and the class:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { ConsentiSetup } from '@consenti/ui'
|
|
41
|
+
import '@consenti/ui/dist/index.css'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### CDN / UMD (no build step)
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.css" />
|
|
48
|
+
<script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
|
|
49
|
+
<script>
|
|
50
|
+
const { ConsentiSetup } = ConsentiUI
|
|
51
|
+
new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
52
|
+
</script>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### ESM in the browser (no bundler)
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<script type="module">
|
|
59
|
+
import { ConsentiSetup } from 'https://esm.sh/@consenti/ui'
|
|
60
|
+
new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
61
|
+
</script>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### CSS options
|
|
65
|
+
|
|
66
|
+
| Approach | How |
|
|
67
|
+
|-----------------------------------|-----------------------------------------------------------------------|
|
|
68
|
+
| Import from package (recommended) | `import '@consenti/ui/dist/index.css'` |
|
|
69
|
+
| Skip all CSS (bring your own) | Set `core.disableCssTemplate: true` — no styles injected |
|
|
70
|
+
| CSS custom properties only | Import CSS, then override `--consenti-*` variables in your stylesheet |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// Simplest possible — built-in GDPR profile, no backend required
|
|
78
|
+
new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A consent banner appears on first visit. Everything below is optional.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Package Exports
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
@consenti/ui ← main entry (ConsentiSetup, ConsentiProfile, etc.)
|
|
89
|
+
@consenti/ui/react ← React hook (useConsent)
|
|
90
|
+
@consenti/ui/vue ← Vue composable (useConsent)
|
|
91
|
+
@consenti/ui/angular ← Angular service (ConsentiService)
|
|
92
|
+
@consenti/ui/testing ← Test utilities
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Full Configuration
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { ConsentiSetup } from '@consenti/ui'
|
|
101
|
+
import '@consenti/ui/dist/index.css'
|
|
102
|
+
|
|
103
|
+
const widget = new ConsentiSetup({
|
|
104
|
+
// ── Required ────────────────────────────────────────────────────────────────
|
|
105
|
+
core: {
|
|
106
|
+
profileId: 1, // 0 = built-in default; >0 = local or API profile
|
|
107
|
+
regulation: 'gdpr', // controls opt-in / opt-out behaviour
|
|
108
|
+
locale: 'en', // BCP 47; falls back to language prefix, then 'en'
|
|
109
|
+
autoHonorGPC: true, // false | true | 'strict'
|
|
110
|
+
storage: 'cookie', // 'cookie' | 'localStorage'
|
|
111
|
+
cookieDomains: '.example.com',
|
|
112
|
+
privacyPolicyUrl: '/privacy',
|
|
113
|
+
signCookies: true,
|
|
114
|
+
cookieSigningKey: 'min-32-char-secret', // client-only mode; use API mode in production
|
|
115
|
+
allowReceipt: true,
|
|
116
|
+
disableCssTemplate: false,
|
|
117
|
+
userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
|
|
118
|
+
theme: {
|
|
119
|
+
bgColor: '#ffffff',
|
|
120
|
+
textColor: '#1a1a1a',
|
|
121
|
+
primaryColor: '#1565c0',
|
|
122
|
+
primaryTextColor: '#ffffff',
|
|
123
|
+
secondaryColor: '#f0f4f8',
|
|
124
|
+
secondaryTextColor: '#1a3460',
|
|
125
|
+
borderColor: '#e2e8f0',
|
|
126
|
+
accentColor: '#d32f2f',
|
|
127
|
+
accentTextColor: '#ffffff',
|
|
128
|
+
fontFamily: 'system-ui, sans-serif',
|
|
129
|
+
fontSizeBase: '14px',
|
|
130
|
+
fontSizeHeading: '18px',
|
|
131
|
+
fontSizeMultiplier: '1',
|
|
132
|
+
borderRadius: '8px',
|
|
133
|
+
buttonBorderRadius: '4px',
|
|
134
|
+
toggleBgOn: '#1565c0',
|
|
135
|
+
toggleBgOff: '#cccccc',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// ── Mount point (optional) ────────────────────────────────────────────────
|
|
140
|
+
rootEl: '#my-consent-wrapper', // CSS selector or HTMLElement; default: appends to body
|
|
141
|
+
|
|
142
|
+
// ── Dark mode (optional) ──────────────────────────────────────────────────
|
|
143
|
+
darkMode: false, // or: window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
144
|
+
|
|
145
|
+
// ── Backend API (optional) ────────────────────────────────────────────────
|
|
146
|
+
api: {
|
|
147
|
+
enabled: true,
|
|
148
|
+
baseUrl: 'https://your-site.com', // default: window.location.origin
|
|
149
|
+
authToken: '', // sent as Authorization: Bearer <token>
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// ── GTM / Google Consent Mode v2 (optional) ───────────────────────────────
|
|
153
|
+
utils: {
|
|
154
|
+
gtm: {
|
|
155
|
+
containerId: 'GTM-XXXXXX',
|
|
156
|
+
dataLayer: 'dataLayer',
|
|
157
|
+
events: [], // [] = all events; list names to filter
|
|
158
|
+
urlPassthrough: true, // cookieless conversion modelling
|
|
159
|
+
adsDataRedaction: false, // redact ad pings when consent denied
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// ── Frontend plugins (optional) ───────────────────────────────────────────
|
|
164
|
+
plugins: [],
|
|
165
|
+
|
|
166
|
+
// ── Runtime profile overrides (optional) ──────────────────────────────────
|
|
167
|
+
profileOverride: {
|
|
168
|
+
mainBanner: { position: 'top' },
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Regulations
|
|
176
|
+
|
|
177
|
+
| Value | Region | Model | Notes |
|
|
178
|
+
|--------------|---------------------|---------------------|------------------------------------------------------------------------|
|
|
179
|
+
| `'gdpr'` | EU / EEA | Opt-in | Default. Banner on first visit; all non-mandatory cookies denied until granted. |
|
|
180
|
+
| `'uk-gdpr'` | UK | Opt-in | Same model as GDPR; ICO-enforced; age gate at 13. |
|
|
181
|
+
| `'ccpa'` | California (US) | Opt-out | All cookies default to `'granted'`; consent written silently; no banner. |
|
|
182
|
+
| `'cpra'` | California (US) | Opt-out / Opt-in | Supersedes CCPA. Opt-out for sale/sharing; opt-in for sensitive data. GPC triggers Do Not Sell + Do Not Share. |
|
|
183
|
+
| `'lgpd'` | Brazil | Opt-in | 10 lawful bases; ANPD-enforced; parental consent gate under-12. |
|
|
184
|
+
| `'dpdpa'` | India | Opt-in | Fiduciary name + grievance officer in modal; GPC ignored. |
|
|
185
|
+
| `'pipeda'` | Canada | Opt-in | PIPEDA + Quebec Law 25 (GDPR-aligned). |
|
|
186
|
+
| `'popia'` | South Africa | Opt-in | 8 processing conditions; Information Regulator-enforced. |
|
|
187
|
+
| `'pdpa-th'` | Thailand | Opt-in | Cross-border transfer rules; PDPC-enforced. |
|
|
188
|
+
| `'appi'` | Japan | Opt-in / Opt-out | Opt-in for sensitive data and overseas transfers; opt-out for general third-party sharing. |
|
|
189
|
+
| `'kvkk'` | Turkey | Opt-in | Explicit consent for sensitive personal data; KVK Board-enforced. |
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## GPC — Global Privacy Control
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
new ConsentiSetup({
|
|
197
|
+
core: {
|
|
198
|
+
regulation: 'gdpr',
|
|
199
|
+
autoHonorGPC: true, // pre-deny 'listenGpc' cookies and show GPC banner variant
|
|
200
|
+
// autoHonorGPC: 'strict' // pre-deny silently — no banner shown at all
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Cookies with `listenGpc: true` in the profile are automatically denied when the signal is detected.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Profiles
|
|
210
|
+
|
|
211
|
+
### Built-in default profile
|
|
212
|
+
|
|
213
|
+
When `core.profileId` is `0` or omitted, the widget uses a built-in English GDPR profile covering the five Google Consent Mode v2 purposes. No configuration needed.
|
|
214
|
+
|
|
215
|
+
### Local profile (no backend)
|
|
216
|
+
|
|
217
|
+
Define a full profile in JavaScript — no server required.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'
|
|
221
|
+
|
|
222
|
+
const profile = new ConsentiProfile({
|
|
223
|
+
defaultLocale: 'en',
|
|
224
|
+
cookies: [
|
|
225
|
+
{ id: 'necessary', mandatory: true, expiry: 365 },
|
|
226
|
+
{ id: 'analytics', listenGpc: true, expiry: 365 },
|
|
227
|
+
{ id: 'marketing', listenGpc: true, expiry: 365, cpraCategory: 'sale' },
|
|
228
|
+
// cpraCategory: 'sale' | 'sharing' | 'sensitive' — used when regulation: 'cpra'
|
|
229
|
+
],
|
|
230
|
+
translations: {
|
|
231
|
+
en: {
|
|
232
|
+
mainBanner: {
|
|
233
|
+
position: 'bottom', // 'top' | 'bottom' | 'middle' | 'left-bottom' | 'right-bottom'
|
|
234
|
+
overlayOpacity: 0, // 0–100
|
|
235
|
+
showClose: false,
|
|
236
|
+
showLocaleSwitcher: false, // true = show locale switcher (requires multiple locales)
|
|
237
|
+
heading: 'We value your privacy',
|
|
238
|
+
headingTag: 'h2', // HTML tag for the heading; default 'h2'
|
|
239
|
+
htmlText: 'We use cookies to improve your experience.',
|
|
240
|
+
buttons: [
|
|
241
|
+
{ text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
|
|
242
|
+
// cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
|
|
243
|
+
{ text: 'Reject All', style: 'secondary', action: 'custom', cookies: '!' },
|
|
244
|
+
{ text: 'Manage Preferences', style: 'secondary', action: 'manage' },
|
|
245
|
+
{ text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
|
|
246
|
+
// action: 'link' + url — renders as a text link below the banner
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
gpcBanner: { // shown instead of mainBanner when GPC detected
|
|
250
|
+
position: 'bottom',
|
|
251
|
+
heading: 'Privacy signal detected',
|
|
252
|
+
headingTag: 'h2',
|
|
253
|
+
showLocaleSwitcher: false,
|
|
254
|
+
htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
|
|
255
|
+
buttons: [
|
|
256
|
+
{ text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
|
|
257
|
+
{ text: 'Manage Preferences', style: 'secondary', action: 'manage' },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
preferenceModal: {
|
|
261
|
+
heading: 'Cookie Preferences',
|
|
262
|
+
headingTag: 'h2', // HTML tag for the modal heading; default 'h2'
|
|
263
|
+
subheading: 'Choose which cookies you allow.',
|
|
264
|
+
htmlText: 'We use different types of cookies. You can enable or disable each category below.',
|
|
265
|
+
position: 'center', // 'left' | 'right' | 'center'
|
|
266
|
+
showClose: true,
|
|
267
|
+
showLocaleSwitcher: false,
|
|
268
|
+
persistent: false, // true = cannot dismiss by clicking outside
|
|
269
|
+
overlayOpacity: 50,
|
|
270
|
+
buttons: [
|
|
271
|
+
{ text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
|
|
272
|
+
{ text: 'Save Preferences', style: 'primary', action: 'submit' },
|
|
273
|
+
{ text: 'Reject All', style: 'text', action: 'custom', cookies: '!' },
|
|
274
|
+
],
|
|
275
|
+
categories: [
|
|
276
|
+
{
|
|
277
|
+
id: 'cat-necessary',
|
|
278
|
+
heading: 'Strictly Necessary',
|
|
279
|
+
headingTag: 'h3', // HTML tag for the category heading; default 'h3'
|
|
280
|
+
htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
|
|
281
|
+
mandatory: true,
|
|
282
|
+
cookies: ['necessary'],
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'cat-analytics',
|
|
286
|
+
heading: 'Analytics',
|
|
287
|
+
htmlText: 'Helps us understand how visitors use the site.',
|
|
288
|
+
type: 'consent', // 'consent' | 'legitimate_interest'
|
|
289
|
+
// type: 'legitimate_interest' — refusal stored as 'objected' instead of 'denied'
|
|
290
|
+
cookies: ['analytics'],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'cat-marketing',
|
|
294
|
+
heading: 'Marketing',
|
|
295
|
+
htmlText: 'Used to personalise ads and measure campaign performance.',
|
|
296
|
+
cookies: ['marketing'],
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
fr: {
|
|
302
|
+
// same shape — add translations for each locale
|
|
303
|
+
mainBanner: { /* ... */ },
|
|
304
|
+
preferenceModal: { /* ... */ },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
new ConsentiSetup({
|
|
310
|
+
core: {
|
|
311
|
+
profileId: profile.getId(), // always use .getId() — never hardcode the ID
|
|
312
|
+
regulation: 'gdpr',
|
|
313
|
+
locale: 'fr',
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
> **Note:** Local profile IDs are auto-assigned starting from `1000` to avoid collisions with API profile IDs (which start from `1`). Always call `profile.getId()`.
|
|
319
|
+
|
|
320
|
+
### API profile (with backend)
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
new ConsentiSetup({
|
|
324
|
+
core: { profileId: 3, regulation: 'gdpr', locale: 'de' },
|
|
325
|
+
api: {
|
|
326
|
+
enabled: true,
|
|
327
|
+
baseUrl: 'https://consent.example.com',
|
|
328
|
+
},
|
|
329
|
+
})
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The widget fetches the profile from the API and falls back to a local profile (or the built-in default) if the request fails.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Cookie Format
|
|
337
|
+
|
|
338
|
+
Consent is persisted as a single cookie named `consenti_{profileId}`.
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
t:{unixTimestamp}::{cookieId}:{status}|{cookieId}:{status}|…[::sig:{hmac}]
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Example:
|
|
345
|
+
```
|
|
346
|
+
t:1782133054::analytics:granted|marketing:denied|necessary:granted::sig:abc123
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Widget API Methods
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
const widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
355
|
+
|
|
356
|
+
// Consent state
|
|
357
|
+
widget.hasConsent() // boolean — true if valid consent record exists
|
|
358
|
+
widget.getConsent() // Record<string, 'granted'|'denied'|'objected'> | null
|
|
359
|
+
widget.getGTMConsent() // consent in GTM / Google Consent Mode v2 format
|
|
360
|
+
widget.getConsentDate() // Date | false — time of last submission
|
|
361
|
+
|
|
362
|
+
// Visibility
|
|
363
|
+
widget.showBanner(gpc?) // show main banner (or GPC variant)
|
|
364
|
+
widget.hideBanner() // hide banner
|
|
365
|
+
widget.showModal() // open preference modal
|
|
366
|
+
widget.hideModal() // close preference modal
|
|
367
|
+
widget.bannerVisibility() // 'main' | 'gpc' | false
|
|
368
|
+
widget.modalVisibility() // 'preference' | false
|
|
369
|
+
|
|
370
|
+
// Actions
|
|
371
|
+
widget.init() // manually start init (when autoInit: false)
|
|
372
|
+
widget.onReady(callback) // called once widget is fully initialised
|
|
373
|
+
widget.switchLocale(locale) // switch locale and re-render (e.g. 'fr', 'de-AT')
|
|
374
|
+
widget.submitConsent(consent) // programmatically submit consent values
|
|
375
|
+
widget.deleteConsent() // delete consent record (cookie + backend if enabled)
|
|
376
|
+
widget.reConsent() // delete consent and re-show banner
|
|
377
|
+
widget.downloadReceipt() // trigger JSON consent receipt download
|
|
378
|
+
widget.destroy() // unmount DOM elements and remove all event listeners
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Manual init
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
const widget = new ConsentiSetup({
|
|
385
|
+
core: {},
|
|
386
|
+
rootEl: '#consent-mount',
|
|
387
|
+
autoInit: false,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// Later, once mount point is in the DOM:
|
|
391
|
+
await widget.init()
|
|
392
|
+
widget.onReady(() => console.log('Ready:', widget.hasConsent()))
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Programmatic consent
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
await widget.submitConsent({
|
|
399
|
+
analytics: 'granted',
|
|
400
|
+
marketing: 'denied',
|
|
401
|
+
necessary: 'granted', // mandatory cookies are always 'granted' regardless
|
|
402
|
+
})
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Events
|
|
408
|
+
|
|
409
|
+
Custom DOM events fire on `document` at every lifecycle step. All are prefixed `consenti:`.
|
|
410
|
+
|
|
411
|
+
| Event | Fired when | Detail type |
|
|
412
|
+
|--------------------------------|----------------------------------------------------------|--------------------------------|
|
|
413
|
+
| `consenti:bannerInitialized` | Widget initialises and determines banner visibility | `BannerInitializedDetail` |
|
|
414
|
+
| `consenti:bannerVisibility` | Banner shows or hides | `BannerVisibilityDetail` |
|
|
415
|
+
| `consenti:modalVisibility` | Preference modal shows or hides | `ModalVisibilityDetail` |
|
|
416
|
+
| `consenti:consentBeingSubmitted` | User clicked a consent button (before API call) | `ConsentBeingSubmittedDetail` |
|
|
417
|
+
| `consenti:consentSubmitted` | Consent saved (cookie written + API call if configured) | `ConsentSubmittedDetail` |
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
document.addEventListener('consenti:consentSubmitted', (e: Event) => {
|
|
421
|
+
const detail = (e as CustomEvent<ConsentSubmittedDetail>).detail
|
|
422
|
+
// detail.consentAction — 'accept_all' | 'reject_all' | 'custom' | 'update'
|
|
423
|
+
// detail.consentJson — { analytics: 'granted', marketing: 'denied', ... }
|
|
424
|
+
// detail.visitorId — stable UUID
|
|
425
|
+
// detail.pageUrl — window.location.href at submission time
|
|
426
|
+
// detail.gpcDetected — boolean
|
|
427
|
+
// detail.timestamp — ISO 8601
|
|
428
|
+
// detail.apiResponse — backend response (if api.enabled: true)
|
|
429
|
+
})
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## GTM / Google Consent Mode v2
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
new ConsentiSetup({
|
|
438
|
+
core: { regulation: 'gdpr' },
|
|
439
|
+
utils: {
|
|
440
|
+
gtm: {
|
|
441
|
+
containerId: 'GTM-XXXXXX',
|
|
442
|
+
urlPassthrough: true, // cookieless conversion modelling
|
|
443
|
+
adsDataRedaction: true, // redact ad pings when consent denied
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Consenti pushes to `window.dataLayer` in the standard Google Consent Mode v2 format:
|
|
450
|
+
|
|
451
|
+
```js
|
|
452
|
+
// On initialisation (default denied state)
|
|
453
|
+
dataLayer.push({ 'event': 'consent_default', 'analytics_storage': 'denied', ... })
|
|
454
|
+
|
|
455
|
+
// On submission
|
|
456
|
+
dataLayer.push({ 'event': 'consent_update', 'analytics_storage': 'granted', 'ad_storage': 'denied', ... })
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`getGTMConsent()` returns consent in GTM format:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
widget.getGTMConsent()
|
|
463
|
+
// {
|
|
464
|
+
// analytics_storage: 'granted',
|
|
465
|
+
// ad_storage: 'denied',
|
|
466
|
+
// ad_user_data: 'denied',
|
|
467
|
+
// ad_personalization: 'denied',
|
|
468
|
+
// functionality_storage: 'granted',
|
|
469
|
+
// }
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## Utilities
|
|
475
|
+
|
|
476
|
+
### ConsentScript — load scripts on consent
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
import { ConsentScript } from '@consenti/ui'
|
|
480
|
+
|
|
481
|
+
new ConsentScript({
|
|
482
|
+
cookieId: 'analytics',
|
|
483
|
+
src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXX',
|
|
484
|
+
onLoad: () => console.log('Analytics loaded'),
|
|
485
|
+
onRevoke: () => console.log('Analytics removed'),
|
|
486
|
+
})
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### CookieTrigger — open banner or modal from any element
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
import { CookieTrigger } from '@consenti/ui'
|
|
493
|
+
|
|
494
|
+
// Attach to existing element
|
|
495
|
+
new CookieTrigger({ selector: '#cookie-settings', action: 'modal' })
|
|
496
|
+
|
|
497
|
+
// Auto-create a button
|
|
498
|
+
new CookieTrigger({
|
|
499
|
+
action: 'modal',
|
|
500
|
+
label: 'Manage Cookies',
|
|
501
|
+
appendTo: document.querySelector('#footer'),
|
|
502
|
+
})
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## Themes & CSS
|
|
508
|
+
|
|
509
|
+
### CSS custom properties
|
|
510
|
+
|
|
511
|
+
Override any token in your own stylesheet:
|
|
512
|
+
|
|
513
|
+
```css
|
|
514
|
+
:root {
|
|
515
|
+
/* Colors */
|
|
516
|
+
--consenti-primary-text: #1a3460;
|
|
517
|
+
--consenti-secondary-text: #555555;
|
|
518
|
+
--consenti-primary-bg: #ffffff;
|
|
519
|
+
--consenti-secondary-bg: #f5f5f5;
|
|
520
|
+
--consenti-overlay-bg: rgba(0, 0, 0, 0.5);
|
|
521
|
+
|
|
522
|
+
/* Buttons */
|
|
523
|
+
--consenti-btn-primary-bg: #1565c0;
|
|
524
|
+
--consenti-btn-primary-text: #ffffff;
|
|
525
|
+
--consenti-btn-secondary-bg: #f5f5f5;
|
|
526
|
+
--consenti-btn-secondary-text: #1a3460;
|
|
527
|
+
--consenti-btn-radius: 6px;
|
|
528
|
+
--consenti-btn-padding: 0.5rem 1.25rem;
|
|
529
|
+
--consenti-btn-font-weight: 600;
|
|
530
|
+
|
|
531
|
+
/* Banner */
|
|
532
|
+
--consenti-banner-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
|
533
|
+
--consenti-banner-radius: 0;
|
|
534
|
+
--consenti-banner-padding: 1.5rem;
|
|
535
|
+
--consenti-banner-max-width: 960px;
|
|
536
|
+
|
|
537
|
+
/* Modal */
|
|
538
|
+
--consenti-modal-radius: 12px;
|
|
539
|
+
--consenti-modal-shadow: 0 8px 32px rgba(0, 0, 0, 0.24);
|
|
540
|
+
--consenti-modal-width: 560px;
|
|
541
|
+
--consenti-modal-max-height: 80vh;
|
|
542
|
+
|
|
543
|
+
/* Toggle */
|
|
544
|
+
--consenti-toggle-on: #1565c0;
|
|
545
|
+
--consenti-toggle-off: #ccc;
|
|
546
|
+
|
|
547
|
+
/* Typography */
|
|
548
|
+
--consenti-font-size-base: 14px;
|
|
549
|
+
--consenti-font-size-mult: 1;
|
|
550
|
+
--consenti-font-family: system-ui, -apple-system, sans-serif;
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Via JS theme config
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
new ConsentiSetup({
|
|
558
|
+
core: {
|
|
559
|
+
regulation: 'gdpr',
|
|
560
|
+
theme: {
|
|
561
|
+
primaryColor: '#7c3aed', // purple brand
|
|
562
|
+
primaryTextColor: '#ffffff',
|
|
563
|
+
borderRadius: '12px',
|
|
564
|
+
buttonBorderRadius: '999px', // pill buttons
|
|
565
|
+
fontFamily: 'Inter, sans-serif',
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### Dark mode
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
new ConsentiSetup({
|
|
575
|
+
core: { profileId: 1 },
|
|
576
|
+
darkMode: true, // or: window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
577
|
+
})
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### BEM class reference
|
|
581
|
+
|
|
582
|
+
**Banner:**
|
|
583
|
+
|
|
584
|
+
| Class | Element |
|
|
585
|
+
|------------------------------------|----------------------------------------------|
|
|
586
|
+
| `.consenti-banner` | Banner root element |
|
|
587
|
+
| `.consenti-banner--top` | Position modifier (`top` / `middle` / `left-bottom` / `right-bottom`) |
|
|
588
|
+
| `.consenti-banner--gpc` | GPC banner modifier |
|
|
589
|
+
| `.consenti-banner__heading` | Banner heading |
|
|
590
|
+
| `.consenti-banner__text` | Banner HTML text |
|
|
591
|
+
| `.consenti-banner__buttons` | Button row |
|
|
592
|
+
| `.consenti-banner__close` | Close × button |
|
|
593
|
+
|
|
594
|
+
**Modal:**
|
|
595
|
+
|
|
596
|
+
| Class | Element |
|
|
597
|
+
|------------------------------------|----------------------------------------------|
|
|
598
|
+
| `.consenti-overlay` | Full-screen overlay backdrop |
|
|
599
|
+
| `.consenti-modal` | Modal root |
|
|
600
|
+
| `.consenti-modal__heading` | Modal heading |
|
|
601
|
+
| `.consenti-modal__categories` | Category list |
|
|
602
|
+
| `.consenti-category` | Single category block |
|
|
603
|
+
| `.consenti-category__toggle` | Category enable/disable toggle |
|
|
604
|
+
| `.consenti-category__toggle--mandatory` | Disabled toggle for mandatory categories |
|
|
605
|
+
| `.consenti-modal__buttons` | Button row |
|
|
606
|
+
|
|
607
|
+
**Buttons:**
|
|
608
|
+
|
|
609
|
+
| Class | Description |
|
|
610
|
+
|--------------------------|--------------------------|
|
|
611
|
+
| `.consenti-btn` | Base button |
|
|
612
|
+
| `.consenti-btn--primary` | Primary CTA (filled) |
|
|
613
|
+
| `.consenti-btn--secondary` | Secondary (outlined) |
|
|
614
|
+
| `.consenti-btn--text` | Text/link style |
|
|
615
|
+
| `.consenti-btn--submit` | Save in modal |
|
|
616
|
+
| `.consenti-btn--manage` | Opens preference modal |
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
## Framework Guides
|
|
621
|
+
|
|
622
|
+
### React
|
|
623
|
+
|
|
624
|
+
```tsx
|
|
625
|
+
'use client' // Next.js App Router
|
|
626
|
+
|
|
627
|
+
import { useEffect } from 'react'
|
|
628
|
+
import { ConsentiSetup } from '@consenti/ui'
|
|
629
|
+
import '@consenti/ui/dist/index.css'
|
|
630
|
+
|
|
631
|
+
export function ConsentSetup() {
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
const widget = new ConsentiSetup({
|
|
634
|
+
core: { regulation: 'gdpr', locale: 'en' },
|
|
635
|
+
})
|
|
636
|
+
return () => widget.destroy()
|
|
637
|
+
}, [])
|
|
638
|
+
|
|
639
|
+
return null
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
#### `useConsent` hook
|
|
644
|
+
|
|
645
|
+
```tsx
|
|
646
|
+
import { useConsent } from '@consenti/ui/react'
|
|
647
|
+
|
|
648
|
+
export function AnalyticsButton() {
|
|
649
|
+
const { hasConsent, consent, showModal } = useConsent()
|
|
650
|
+
|
|
651
|
+
if (!hasConsent) return <button onClick={showModal}>Enable Analytics</button>
|
|
652
|
+
|
|
653
|
+
return <span>{consent?.analytics === 'granted' ? 'Analytics on' : 'Analytics off'}</span>
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### Next.js App Router
|
|
658
|
+
|
|
659
|
+
```tsx
|
|
660
|
+
// app/layout.tsx
|
|
661
|
+
import { ConsentSetup } from '@/components/ConsentSetup'
|
|
662
|
+
|
|
663
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
664
|
+
return (
|
|
665
|
+
<html lang="en">
|
|
666
|
+
<body>
|
|
667
|
+
<ConsentSetup />
|
|
668
|
+
{children}
|
|
669
|
+
</body>
|
|
670
|
+
</html>
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
```tsx
|
|
676
|
+
// components/ConsentSetup.tsx
|
|
677
|
+
'use client'
|
|
678
|
+
|
|
679
|
+
import { useEffect, useRef } from 'react'
|
|
680
|
+
import type { ConsentiSetup as WidgetType } from '@consenti/ui'
|
|
681
|
+
import '@consenti/ui/dist/index.css'
|
|
682
|
+
|
|
683
|
+
export function ConsentSetup() {
|
|
684
|
+
const widgetRef = useRef<WidgetType | null>(null)
|
|
685
|
+
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
let widget: WidgetType
|
|
688
|
+
import('@consenti/ui').then(({ ConsentiSetup }) => {
|
|
689
|
+
widget = new ConsentiSetup({
|
|
690
|
+
core: { regulation: 'gdpr', locale: 'en', autoHonorGPC: true },
|
|
691
|
+
api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
|
|
692
|
+
})
|
|
693
|
+
widgetRef.current = widget
|
|
694
|
+
})
|
|
695
|
+
return () => widgetRef.current?.destroy()
|
|
696
|
+
}, [])
|
|
697
|
+
|
|
698
|
+
return null
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### Vue 3 / Nuxt
|
|
703
|
+
|
|
704
|
+
```vue
|
|
705
|
+
<script setup lang="ts">
|
|
706
|
+
import { onMounted, onBeforeUnmount } from 'vue'
|
|
707
|
+
|
|
708
|
+
let widget: unknown = null
|
|
709
|
+
|
|
710
|
+
onMounted(async () => {
|
|
711
|
+
const { ConsentiSetup } = await import('@consenti/ui')
|
|
712
|
+
await import('@consenti/ui/dist/index.css')
|
|
713
|
+
widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
onBeforeUnmount(() => (widget as { destroy?: () => void })?.destroy?.())
|
|
717
|
+
</script>
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
#### Vue composable
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
import { useConsent } from '@consenti/ui/vue'
|
|
724
|
+
const { hasConsent, consent, showModal } = useConsent()
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
### Angular
|
|
728
|
+
|
|
729
|
+
```ts
|
|
730
|
+
// consent.service.ts
|
|
731
|
+
import { Injectable, OnDestroy, inject, PLATFORM_ID } from '@angular/core'
|
|
732
|
+
import { isPlatformBrowser } from '@angular/common'
|
|
733
|
+
|
|
734
|
+
@Injectable({ providedIn: 'root' })
|
|
735
|
+
export class ConsentService implements OnDestroy {
|
|
736
|
+
private platformId = inject(PLATFORM_ID)
|
|
737
|
+
private widget: unknown = null
|
|
738
|
+
|
|
739
|
+
async init() {
|
|
740
|
+
if (!isPlatformBrowser(this.platformId)) return
|
|
741
|
+
const { ConsentiSetup } = await import('@consenti/ui')
|
|
742
|
+
this.widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
ngOnDestroy() {
|
|
746
|
+
(this.widget as { destroy?: () => void })?.destroy?.()
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Or use the built-in Angular service:
|
|
752
|
+
|
|
753
|
+
```ts
|
|
754
|
+
import { ConsentiService } from '@consenti/ui/angular'
|
|
755
|
+
|
|
756
|
+
@Component({ /* ... */ })
|
|
757
|
+
export class MyComponent {
|
|
758
|
+
consent = inject(ConsentiService)
|
|
759
|
+
// consent.hasConsent() | consent.showModal() | consent.getConsent()
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Vanilla JS
|
|
764
|
+
|
|
765
|
+
```js
|
|
766
|
+
import { ConsentiSetup } from '@consenti/ui'
|
|
767
|
+
import '@consenti/ui/dist/index.css'
|
|
768
|
+
|
|
769
|
+
const widget = new ConsentiSetup({ core: { regulation: 'gdpr', locale: 'en' } })
|
|
770
|
+
|
|
771
|
+
document.querySelector('#cookie-settings')?.addEventListener('click', () => {
|
|
772
|
+
widget.showModal()
|
|
773
|
+
})
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
## Plugins
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
import { ConsentiPlugin, ConsentiSetup } from '@consenti/ui'
|
|
782
|
+
|
|
783
|
+
class MyPlugin extends ConsentiPlugin {
|
|
784
|
+
initialize(widget: ConsentiSetup) {
|
|
785
|
+
console.log('Consenti ready')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
destroy() {}
|
|
789
|
+
|
|
790
|
+
onConsentSubmit(consent: Record<string, string>) {
|
|
791
|
+
fetch('/my-api/consent', { method: 'POST', body: JSON.stringify(consent) })
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
onBannerShow() {}
|
|
795
|
+
onBannerHide() {}
|
|
796
|
+
onModalShow() {}
|
|
797
|
+
onModalHide() {}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
new ConsentiSetup({
|
|
801
|
+
core: { regulation: 'gdpr' },
|
|
802
|
+
plugins: [new MyPlugin()],
|
|
803
|
+
})
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
---
|
|
807
|
+
|
|
808
|
+
## SSR / Next.js / Nuxt
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
// Safe to call during SSR — silently no-ops, never touches the DOM:
|
|
812
|
+
const widget = new ConsentiSetup({ core: { regulation: 'gdpr' } })
|
|
813
|
+
// All browser API access is guarded by isClient() internally.
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
// SSR-safe React hook:
|
|
818
|
+
import { useConsent } from '@consenti/ui/react'
|
|
819
|
+
const { hasConsent } = useConsent() // returns false during SSR
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
---
|
|
823
|
+
|
|
824
|
+
## Testing Utilities
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
import {
|
|
828
|
+
mockAllGranted,
|
|
829
|
+
mockAllDenied,
|
|
830
|
+
simulateConsentSubmitted,
|
|
831
|
+
} from '@consenti/ui/testing'
|
|
832
|
+
|
|
833
|
+
mockAllGranted() // fake-grant all cookies for unit tests
|
|
834
|
+
mockAllDenied() // fake-deny all
|
|
835
|
+
simulateConsentSubmitted({ analytics: 'granted' })
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
## Minimal Recipes
|
|
841
|
+
|
|
842
|
+
```ts
|
|
843
|
+
// CCPA opt-out (no banner shown)
|
|
844
|
+
new ConsentiSetup({ core: { regulation: 'ccpa' } })
|
|
845
|
+
|
|
846
|
+
// Cross-subdomain consent
|
|
847
|
+
new ConsentiSetup({ core: { regulation: 'gdpr', storage: 'cookie', cookieDomains: '.example.com' } })
|
|
848
|
+
|
|
849
|
+
// Authenticated user — cross-device sync via API
|
|
850
|
+
new ConsentiSetup({ core: { regulation: 'gdpr', userId: '{{ server_user_id }}' }, api: { enabled: true } })
|
|
851
|
+
|
|
852
|
+
// GPC strict mode + GTM
|
|
853
|
+
new ConsentiSetup({
|
|
854
|
+
core: { regulation: 'gdpr', autoHonorGPC: 'strict' },
|
|
855
|
+
utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
|
|
856
|
+
})
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
861
|
+
## TypeScript
|
|
862
|
+
|
|
863
|
+
All types are exported from the main entry:
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
import type {
|
|
867
|
+
ConsentiConfig, // top-level config object
|
|
868
|
+
CoreConfig, // core section
|
|
869
|
+
ApiConfig, // api section
|
|
870
|
+
UtilsConfig, // utils section
|
|
871
|
+
GtmConfig, // utils.gtm section
|
|
872
|
+
ThemeConfig, // core.theme section
|
|
873
|
+
ConsentValue, // 'granted' | 'denied' | 'objected'
|
|
874
|
+
ConsentiProfile, // local profile class
|
|
875
|
+
} from '@consenti/ui'
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
---
|
|
879
|
+
|
|
880
|
+
## Contributing
|
|
881
|
+
|
|
882
|
+
See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the monorepo root.
|
|
883
|
+
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
## License
|
|
887
|
+
|
|
888
|
+
Apache 2.0 — see [LICENSE](../../LICENSE).
|