keksi 0.0.0-stage → 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 +159 -2
- package/dist/config.d.ts +9 -0
- package/dist/i18n.d.ts +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +324 -0
- package/dist/playwright.d.ts +20 -0
- package/dist/playwright.js +258 -0
- package/dist/providers.d.ts +2 -0
- package/dist/render.d.ts +16 -0
- package/dist/runtime.d.ts +27 -0
- package/dist/styles.d.ts +1 -0
- package/dist/types.d.ts +60 -0
- package/package.json +43 -3
package/README.md
CHANGED
|
@@ -1,3 +1,160 @@
|
|
|
1
|
-
#
|
|
1
|
+
# keksi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lean cookie consent for our client sites: Google Analytics 4, Google Ads, Meta Pixel and YouTube embeds, in Finnish,
|
|
4
|
+
Swedish and English.
|
|
5
|
+
|
|
6
|
+
keksi loads the trackers itself, and only after consent. You don't add their script tags; you write their ids into
|
|
7
|
+
the config. Nothing has to be blocked or intercepted, because nothing is on the page until keksi adds it.
|
|
8
|
+
|
|
9
|
+
- About 5 KB gzipped per page, inline: banner, settings, styles and a 2.4 KB runtime. No requests, no dependencies.
|
|
10
|
+
- Rendered on the server, works with static pages, no layout shift. With no providers configured it renders nothing.
|
|
11
|
+
- The cookie policy table comes from the same data, so it can't drift from what the site loads.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// keksi.config.ts
|
|
17
|
+
import { defineConfig } from 'keksi';
|
|
18
|
+
|
|
19
|
+
export default defineConfig({
|
|
20
|
+
ga: 'G-XXXXXXX',
|
|
21
|
+
googleAds: 'AW-123456789',
|
|
22
|
+
meta: '1234567890',
|
|
23
|
+
youtube: true,
|
|
24
|
+
policyUrl: '/evasteet',
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Render it right after `<body>`, and give the footer a way to reopen the settings:
|
|
29
|
+
|
|
30
|
+
```astro
|
|
31
|
+
---
|
|
32
|
+
// Astro layout
|
|
33
|
+
import { renderKeksi } from 'keksi';
|
|
34
|
+
import config from '../../keksi.config';
|
|
35
|
+
---
|
|
36
|
+
<body>
|
|
37
|
+
<Fragment set:html={renderKeksi(config, { lang: 'fi' })} />
|
|
38
|
+
...
|
|
39
|
+
<button type="button" data-keksi-open>Evästeasetukset</button>
|
|
40
|
+
</body>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// SvelteKit: src/hooks.server.ts. Keeps keksi out of the client bundle and outside the Svelte root.
|
|
45
|
+
import type { Handle } from '@sveltejs/kit/hooks';
|
|
46
|
+
import { renderKeksi } from 'keksi';
|
|
47
|
+
import config from '../keksi.config';
|
|
48
|
+
|
|
49
|
+
const keksi = renderKeksi(config, { lang: 'fi' });
|
|
50
|
+
|
|
51
|
+
export const handle: Handle = ({ event, resolve }) =>
|
|
52
|
+
resolve(event, { transformPageChunk: ({ html }) => html.replace(/<body([^>]*)>/, `<body$1>${keksi}`) });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Policy page:
|
|
56
|
+
|
|
57
|
+
```astro
|
|
58
|
+
<Fragment set:html={cookieTable(config, { lang: 'fi' })} />
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
In SvelteKit, return `cookieTable(...)` from a prerendered `+page.server.ts` and render it with `{@html}`.
|
|
62
|
+
|
|
63
|
+
## Using the trackers
|
|
64
|
+
|
|
65
|
+
`gtag()` and `fbq()` exist from the start, so page code can call them at any time:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
gtag('event', 'generate_lead');
|
|
69
|
+
fbq('track', 'Lead');
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Calls made before consent wait in the queue. If the visitor consents on that page, they're sent after the tags are
|
|
73
|
+
configured. Otherwise they're never sent. Single-page navigation is tracked by GA4 and Meta Pixel themselves.
|
|
74
|
+
|
|
75
|
+
Google gets Consent Mode v2 signals: everything `denied` by default, updated to match the choice. Only basic mode,
|
|
76
|
+
because gtag.js isn't loaded at all before consent.
|
|
77
|
+
|
|
78
|
+
## YouTube
|
|
79
|
+
|
|
80
|
+
```html
|
|
81
|
+
<keksi-youtube video="aqz-KE-bpKQ" label="Esittelyvideo"></keksi-youtube>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The element shows a placeholder with a "Show video" button, plus an "Open on YouTube" link. The click loads that
|
|
85
|
+
one video from `youtube-nocookie.com`. With marketing consent, videos load directly. Add `poster="/img/poster.jpg"`
|
|
86
|
+
for a self-hosted thumbnail. Thumbnails from YouTube are not used, since those requests would go to Google before
|
|
87
|
+
consent.
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
window.keksi.consent; // { analytics, marketing } or null
|
|
93
|
+
window.keksi.open();
|
|
94
|
+
window.keksi.save({ marketing: true });
|
|
95
|
+
window.keksi.acceptAll();
|
|
96
|
+
window.keksi.rejectAll();
|
|
97
|
+
document.addEventListener('keksi:change', (e) => e.detail);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Withdrawal
|
|
101
|
+
|
|
102
|
+
When a category is withdrawn, keksi deletes that category's cookies and localStorage keys on this site, on every
|
|
103
|
+
parent domain, and then reloads, because tags that are already running can't be unloaded. Cookies set by Google or
|
|
104
|
+
Meta on their own domains (`IDE`, `fr`, YouTube's) can't be deleted from our site. They are listed on the policy page
|
|
105
|
+
as third-party cookies.
|
|
106
|
+
|
|
107
|
+
## Testing
|
|
108
|
+
|
|
109
|
+
The examples use the same helpers you'd use in a client project's Playwright tests:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { consentCookie, watchTracking } from 'keksi/playwright';
|
|
113
|
+
|
|
114
|
+
test('nothing tracks before consent', async ({ page }) => {
|
|
115
|
+
const tracking = watchTracking(page, config, { allow: ['session'] });
|
|
116
|
+
await page.goto('/');
|
|
117
|
+
await page.waitForLoadState('networkidle');
|
|
118
|
+
await tracking.check(); // throws on requests to Google, Meta or YouTube, or on stored data
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Skip the banner in other tests
|
|
122
|
+
await context.addCookies([consentCookie(config, baseURL, { analytics: true })]);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Theming and texts
|
|
126
|
+
|
|
127
|
+
The styles have zero specificity, so any site rule wins. Set `--keksi-bg`, `--keksi-fg`, `--keksi-line`,
|
|
128
|
+
`--keksi-radius` and `--keksi-focus`, or style `.keksi`, `.keksi-dialog` and `.keksi-btn`. Override texts per
|
|
129
|
+
language with `texts: { fi: { intro: '…' } }`. Pass `nonce` to `renderKeksi` for a CSP with nonces.
|
|
130
|
+
|
|
131
|
+
## Consent record
|
|
132
|
+
|
|
133
|
+
The `keksi` cookie holds the version, time, a random id and the choices, for 365 days (`cookie.days`). The version
|
|
134
|
+
changes when a provider is added or removed, so everyone is asked again. Ids and texts can change under the same
|
|
135
|
+
version.
|
|
136
|
+
|
|
137
|
+
## What the law requires and how keksi meets it
|
|
138
|
+
|
|
139
|
+
| Requirement | Basis | keksi |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| No non-essential storage or access before consent | Act 917/2014 §205 | Trackers aren't loaded until consent |
|
|
142
|
+
| Purpose, duration and third parties disclosed | §205, CJEU *Planet49* | Providers on the first layer, cookies and durations in settings and on the policy page |
|
|
143
|
+
| Separate choices per purpose, rejecting as easy as accepting | GDPR Art. 4(11), 7; Traficom 2024 decisions | One switch per category, "Reject all" on the first layer, same button style |
|
|
144
|
+
| Withdrawal as easy as consent, with real effect | GDPR Art. 7(3) | `data-keksi-open` anywhere, deletes stored data |
|
|
145
|
+
| Able to show consent was given | GDPR Art. 7(1) | Consent cookie with id, time and version |
|
|
146
|
+
| Transfers outside the EU disclosed | GDPR Art. 13(1)(f) | DPF transfer stated per provider |
|
|
147
|
+
|
|
148
|
+
## Adding a provider
|
|
149
|
+
|
|
150
|
+
Add its disclosure and cookies to `src/providers.ts`, a config key with the same id to `KeksiConfig`, and its loading
|
|
151
|
+
to `load()` in `src/runtime.ts`.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
pnpm install
|
|
157
|
+
pnpm build # dist/, prints the runtime size
|
|
158
|
+
pnpm test # unit tests
|
|
159
|
+
pnpm test:e2e # both examples in Chromium
|
|
160
|
+
```
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Texts } from './i18n.js';
|
|
2
|
+
import type { Category, KeksiConfig, Locale, Provider, RuntimeData } from './types.js';
|
|
3
|
+
export declare function defineConfig(config: KeksiConfig): KeksiConfig;
|
|
4
|
+
export declare function activeProviders(config: KeksiConfig): Provider[];
|
|
5
|
+
export declare function categoriesOf(config: KeksiConfig): Category[];
|
|
6
|
+
export declare function textsFor(config: KeksiConfig, locale: Locale): Texts;
|
|
7
|
+
/** Changes when a provider is added or removed, which asks everyone again. Ids and texts can change freely. */
|
|
8
|
+
export declare function version(config: KeksiConfig): string;
|
|
9
|
+
export declare function runtimeData(config: KeksiConfig, locale: Locale): RuntimeData;
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Locale } from './types.js';
|
|
2
|
+
declare const fi: {
|
|
3
|
+
title: string;
|
|
4
|
+
intro: string;
|
|
5
|
+
transfer: string;
|
|
6
|
+
more: string;
|
|
7
|
+
accept: string;
|
|
8
|
+
reject: string;
|
|
9
|
+
settings: string;
|
|
10
|
+
settingsTitle: string;
|
|
11
|
+
save: string;
|
|
12
|
+
close: string;
|
|
13
|
+
alwaysOn: string;
|
|
14
|
+
necessary: string;
|
|
15
|
+
necessaryInfo: string;
|
|
16
|
+
analytics: string;
|
|
17
|
+
analyticsInfo: string;
|
|
18
|
+
marketing: string;
|
|
19
|
+
marketingInfo: string;
|
|
20
|
+
providers: string;
|
|
21
|
+
thisSite: string;
|
|
22
|
+
consentPurpose: string;
|
|
23
|
+
privacyPolicy: string;
|
|
24
|
+
dpf: string;
|
|
25
|
+
name: string;
|
|
26
|
+
duration: string;
|
|
27
|
+
setBy: string;
|
|
28
|
+
session: string;
|
|
29
|
+
persistent: string;
|
|
30
|
+
y: string;
|
|
31
|
+
mo: string;
|
|
32
|
+
d: string;
|
|
33
|
+
min: string;
|
|
34
|
+
ytText: string;
|
|
35
|
+
ytButton: string;
|
|
36
|
+
ytOpen: string;
|
|
37
|
+
};
|
|
38
|
+
export type Texts = typeof fi;
|
|
39
|
+
export declare const texts: Record<Locale, Texts>;
|
|
40
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { defineConfig, version } from './config.js';
|
|
2
|
+
export { cookieTable, renderKeksi, type CookieTableOptions, type RenderOptions } from './render.js';
|
|
3
|
+
export { providers } from './providers.js';
|
|
4
|
+
export type { KeksiApi } from './runtime.js';
|
|
5
|
+
export type { Texts } from './i18n.js';
|
|
6
|
+
export type * from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// src/i18n.ts
|
|
2
|
+
var fi = {
|
|
3
|
+
title: "Ev\xE4steet",
|
|
4
|
+
intro: "K\xE4yt\xE4mme v\xE4ltt\xE4m\xE4tt\xF6mien ev\xE4steiden lis\xE4ksi seuraavia palveluita vain, jos annat siihen luvan:",
|
|
5
|
+
transfer: "Palveluntarjoajat voivat siirt\xE4\xE4 tietoja Yhdysvaltoihin. Voit muuttaa valintaasi milloin tahansa.",
|
|
6
|
+
more: "Lue lis\xE4\xE4",
|
|
7
|
+
accept: "Hyv\xE4ksy kaikki",
|
|
8
|
+
reject: "Hylk\xE4\xE4 kaikki",
|
|
9
|
+
settings: "Asetukset",
|
|
10
|
+
settingsTitle: "Ev\xE4steasetukset",
|
|
11
|
+
save: "Tallenna valinnat",
|
|
12
|
+
close: "Sulje",
|
|
13
|
+
alwaysOn: "Aina k\xE4yt\xF6ss\xE4",
|
|
14
|
+
necessary: "V\xE4ltt\xE4m\xE4tt\xF6m\xE4t",
|
|
15
|
+
necessaryInfo: "Sivuston toiminnan kannalta v\xE4ltt\xE4m\xE4tt\xF6m\xE4t, kuten ev\xE4stevalintasi tallentaminen. Eiv\xE4t vaadi suostumusta.",
|
|
16
|
+
analytics: "Analytiikka",
|
|
17
|
+
analyticsInfo: "Mittaamme sivuston k\xE4ytt\xF6\xE4, jotta voimme kehitt\xE4\xE4 sit\xE4.",
|
|
18
|
+
marketing: "Markkinointi",
|
|
19
|
+
marketingInfo: "Mainonnan kohdentaminen ja tehon mittaaminen sek\xE4 upotetut sis\xE4ll\xF6t, kuten videot.",
|
|
20
|
+
providers: "Palveluntarjoajat",
|
|
21
|
+
thisSite: "T\xE4m\xE4 sivusto",
|
|
22
|
+
consentPurpose: "Tallentaa ev\xE4stevalintasi.",
|
|
23
|
+
privacyPolicy: "Tietosuojaseloste",
|
|
24
|
+
dpf: "Tietoja voidaan siirt\xE4\xE4 Yhdysvaltoihin EU:n ja Yhdysvaltojen v\xE4lisen tietosuojakehyksen (DPF) perusteella.",
|
|
25
|
+
name: "Nimi",
|
|
26
|
+
duration: "Kesto",
|
|
27
|
+
setBy: "Asettaja",
|
|
28
|
+
session: "istunto",
|
|
29
|
+
persistent: "kunnes poistetaan",
|
|
30
|
+
y: "vuosi|vuotta",
|
|
31
|
+
mo: "kuukausi|kuukautta",
|
|
32
|
+
d: "p\xE4iv\xE4|p\xE4iv\xE4\xE4",
|
|
33
|
+
min: "minuutti|minuuttia",
|
|
34
|
+
ytText: "Video ladataan YouTubesta. YouTube (Google) asettaa ev\xE4steit\xE4 ja voi siirt\xE4\xE4 tietoja Yhdysvaltoihin.",
|
|
35
|
+
ytButton: "N\xE4yt\xE4 video",
|
|
36
|
+
ytOpen: "Avaa YouTubessa"
|
|
37
|
+
};
|
|
38
|
+
var sv = {
|
|
39
|
+
title: "Kakor",
|
|
40
|
+
intro: "Ut\xF6ver n\xF6dv\xE4ndiga kakor anv\xE4nder vi f\xF6ljande tj\xE4nster endast om du ger ditt samtycke:",
|
|
41
|
+
transfer: "Tj\xE4nsteleverant\xF6rerna kan \xF6verf\xF6ra uppgifter till USA. Du kan \xE4ndra ditt val n\xE4r som helst.",
|
|
42
|
+
more: "L\xE4s mer",
|
|
43
|
+
accept: "Godk\xE4nn alla",
|
|
44
|
+
reject: "Avvisa alla",
|
|
45
|
+
settings: "Inst\xE4llningar",
|
|
46
|
+
settingsTitle: "Inst\xE4llningar f\xF6r kakor",
|
|
47
|
+
save: "Spara val",
|
|
48
|
+
close: "St\xE4ng",
|
|
49
|
+
alwaysOn: "Alltid p\xE5",
|
|
50
|
+
necessary: "N\xF6dv\xE4ndiga",
|
|
51
|
+
necessaryInfo: "N\xF6dv\xE4ndiga f\xF6r att webbplatsen ska fungera, till exempel f\xF6r att spara ditt val. Kr\xE4ver inget samtycke.",
|
|
52
|
+
analytics: "Analys",
|
|
53
|
+
analyticsInfo: "Vi m\xE4ter hur webbplatsen anv\xE4nds f\xF6r att kunna utveckla den.",
|
|
54
|
+
marketing: "Marknadsf\xF6ring",
|
|
55
|
+
marketingInfo: "Riktad annonsering och m\xE4tning av annonsernas effekt samt inb\xE4ddat inneh\xE5ll, till exempel videor.",
|
|
56
|
+
providers: "Tj\xE4nsteleverant\xF6rer",
|
|
57
|
+
thisSite: "Denna webbplats",
|
|
58
|
+
consentPurpose: "Sparar ditt val av kakor.",
|
|
59
|
+
privacyPolicy: "Dataskyddsbeskrivning",
|
|
60
|
+
dpf: "Uppgifter kan \xF6verf\xF6ras till USA med st\xF6d av ramverket f\xF6r dataskydd mellan EU och USA (DPF).",
|
|
61
|
+
name: "Namn",
|
|
62
|
+
duration: "Varaktighet",
|
|
63
|
+
setBy: "S\xE4tts av",
|
|
64
|
+
session: "session",
|
|
65
|
+
persistent: "tills den raderas",
|
|
66
|
+
y: "\xE5r|\xE5r",
|
|
67
|
+
mo: "m\xE5nad|m\xE5nader",
|
|
68
|
+
d: "dag|dagar",
|
|
69
|
+
min: "minut|minuter",
|
|
70
|
+
ytText: "Videon laddas fr\xE5n YouTube. YouTube (Google) s\xE4tter kakor och kan \xF6verf\xF6ra uppgifter till USA.",
|
|
71
|
+
ytButton: "Visa video",
|
|
72
|
+
ytOpen: "\xD6ppna p\xE5 YouTube"
|
|
73
|
+
};
|
|
74
|
+
var en = {
|
|
75
|
+
title: "Cookies",
|
|
76
|
+
intro: "Besides necessary cookies, we use the following services only if you allow it:",
|
|
77
|
+
transfer: "The providers may transfer data to the United States. You can change your choice at any time.",
|
|
78
|
+
more: "Read more",
|
|
79
|
+
accept: "Accept all",
|
|
80
|
+
reject: "Reject all",
|
|
81
|
+
settings: "Settings",
|
|
82
|
+
settingsTitle: "Cookie settings",
|
|
83
|
+
save: "Save choices",
|
|
84
|
+
close: "Close",
|
|
85
|
+
alwaysOn: "Always on",
|
|
86
|
+
necessary: "Necessary",
|
|
87
|
+
necessaryInfo: "Needed for the site to work, such as storing your cookie choice. No consent needed.",
|
|
88
|
+
analytics: "Analytics",
|
|
89
|
+
analyticsInfo: "We measure how the site is used so we can improve it.",
|
|
90
|
+
marketing: "Marketing",
|
|
91
|
+
marketingInfo: "Ad targeting and measurement, and embedded content such as videos.",
|
|
92
|
+
providers: "Providers",
|
|
93
|
+
thisSite: "This site",
|
|
94
|
+
consentPurpose: "Stores your cookie choice.",
|
|
95
|
+
privacyPolicy: "Privacy policy",
|
|
96
|
+
dpf: "Data may be transferred to the United States under the EU\u2013US Data Privacy Framework (DPF).",
|
|
97
|
+
name: "Name",
|
|
98
|
+
duration: "Duration",
|
|
99
|
+
setBy: "Set by",
|
|
100
|
+
session: "session",
|
|
101
|
+
persistent: "until deleted",
|
|
102
|
+
y: "year|years",
|
|
103
|
+
mo: "month|months",
|
|
104
|
+
d: "day|days",
|
|
105
|
+
min: "minute|minutes",
|
|
106
|
+
ytText: "The video loads from YouTube. YouTube (Google) sets cookies and may transfer data to the United States.",
|
|
107
|
+
ytButton: "Show video",
|
|
108
|
+
ytOpen: "Open on YouTube"
|
|
109
|
+
};
|
|
110
|
+
var texts = { fi, sv, en };
|
|
111
|
+
|
|
112
|
+
// src/providers.ts
|
|
113
|
+
var GOOGLE = { company: "Google Ireland Limited", privacyPolicy: "https://policies.google.com/privacy", transfer: "dpf" };
|
|
114
|
+
var META = { company: "Meta Platforms Ireland Limited", privacyPolicy: "https://www.facebook.com/privacy/policy/", transfer: "dpf" };
|
|
115
|
+
var providers = [
|
|
116
|
+
{
|
|
117
|
+
id: "ga",
|
|
118
|
+
name: "Google Analytics",
|
|
119
|
+
...GOOGLE,
|
|
120
|
+
category: "analytics",
|
|
121
|
+
purpose: {
|
|
122
|
+
fi: "K\xE4vij\xE4tilastot: k\xE4vij\xE4m\xE4\xE4r\xE4t, liikenteen l\xE4hteet ja sivujen k\xE4ytt\xF6.",
|
|
123
|
+
sv: "Bes\xF6ksstatistik: antal bes\xF6kare, trafikk\xE4llor och hur sidorna anv\xE4nds.",
|
|
124
|
+
en: "Visitor statistics: visitor numbers, traffic sources and page usage."
|
|
125
|
+
},
|
|
126
|
+
cookies: [
|
|
127
|
+
{ name: "_ga", duration: "2y" },
|
|
128
|
+
{ name: "_ga_*", duration: "2y" }
|
|
129
|
+
],
|
|
130
|
+
hosts: ["google-analytics.com", "analytics.google.com", "googletagmanager.com"]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "googleAds",
|
|
134
|
+
name: "Google Ads",
|
|
135
|
+
...GOOGLE,
|
|
136
|
+
category: "marketing",
|
|
137
|
+
purpose: {
|
|
138
|
+
fi: "Mainonnan tehon mittaaminen (konversiot) ja mainosten kohdentaminen sivuston k\xE4vij\xF6ille.",
|
|
139
|
+
sv: "M\xE4tning av annonsernas effekt (konverteringar) och riktad annonsering till bes\xF6kare.",
|
|
140
|
+
en: "Measuring ad performance (conversions) and showing ads to site visitors."
|
|
141
|
+
},
|
|
142
|
+
cookies: [
|
|
143
|
+
{ name: "_gcl_au", duration: "90d" },
|
|
144
|
+
{ name: "_gcl_aw", duration: "90d" },
|
|
145
|
+
{ name: "_gcl_dc", duration: "90d" },
|
|
146
|
+
{ name: "_gcl_gs", duration: "90d" },
|
|
147
|
+
{ name: "_gac_*", duration: "90d" },
|
|
148
|
+
{ name: "_gcl_ls", duration: "persistent", storage: "localStorage" },
|
|
149
|
+
{ name: "IDE", duration: "13mo", host: "doubleclick.net" },
|
|
150
|
+
{ name: "DSID", duration: "14d", host: "doubleclick.net" },
|
|
151
|
+
{ name: "test_cookie", duration: "15min", host: "doubleclick.net" }
|
|
152
|
+
],
|
|
153
|
+
hosts: ["googletagmanager.com", "googleadservices.com", "doubleclick.net", "googlesyndication.com"]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "meta",
|
|
157
|
+
name: "Meta Pixel",
|
|
158
|
+
...META,
|
|
159
|
+
category: "marketing",
|
|
160
|
+
purpose: {
|
|
161
|
+
fi: "Mainonnan tehon mittaaminen ja mainosten kohdentaminen Facebookissa ja Instagramissa.",
|
|
162
|
+
sv: "M\xE4tning av annonsernas effekt och riktad annonsering p\xE5 Facebook och Instagram.",
|
|
163
|
+
en: "Measuring ad performance and targeting ads on Facebook and Instagram."
|
|
164
|
+
},
|
|
165
|
+
cookies: [
|
|
166
|
+
{ name: "_fbp", duration: "90d" },
|
|
167
|
+
{ name: "_fbc", duration: "90d" },
|
|
168
|
+
{ name: "fr", duration: "90d", host: "facebook.com" }
|
|
169
|
+
],
|
|
170
|
+
hosts: ["facebook.net", "facebook.com"]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "youtube",
|
|
174
|
+
name: "YouTube",
|
|
175
|
+
...GOOGLE,
|
|
176
|
+
category: "marketing",
|
|
177
|
+
purpose: {
|
|
178
|
+
fi: "Upotettujen videoiden toistaminen. YouTube voi k\xE4ytt\xE4\xE4 tietoja mainontaan.",
|
|
179
|
+
sv: "Uppspelning av inb\xE4ddade videor. YouTube kan anv\xE4nda uppgifterna f\xF6r annonsering.",
|
|
180
|
+
en: "Playing embedded videos. YouTube may use the data for advertising."
|
|
181
|
+
},
|
|
182
|
+
cookies: [
|
|
183
|
+
{ name: "YSC", duration: "session", host: "youtube.com" },
|
|
184
|
+
{ name: "VISITOR_INFO1_LIVE", duration: "6mo", host: "youtube.com" },
|
|
185
|
+
{ name: "VISITOR_PRIVACY_METADATA", duration: "6mo", host: "youtube.com" },
|
|
186
|
+
{ name: "__Secure-YNID", duration: "6mo", host: "youtube.com" },
|
|
187
|
+
{ name: "__Secure-ROLLOUT_TOKEN", duration: "6mo", host: "youtube.com" },
|
|
188
|
+
{ name: "PREF", duration: "8mo", host: "youtube.com" },
|
|
189
|
+
{ name: "yt-remote-*, ytidb::*, yt-player-*", duration: "persistent", host: "youtube-nocookie.com", storage: "localStorage" }
|
|
190
|
+
],
|
|
191
|
+
hosts: ["youtube.com", "youtube-nocookie.com", "ytimg.com", "googlevideo.com"]
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// src/config.ts
|
|
196
|
+
function defineConfig(config) {
|
|
197
|
+
return config;
|
|
198
|
+
}
|
|
199
|
+
var list = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
200
|
+
function activeProviders(config) {
|
|
201
|
+
return providers.filter((p) => p.id === "youtube" ? config.youtube : list(config[p.id]).length);
|
|
202
|
+
}
|
|
203
|
+
function categoriesOf(config) {
|
|
204
|
+
return ["analytics", "marketing"].filter((c) => activeProviders(config).some((p) => p.category === c));
|
|
205
|
+
}
|
|
206
|
+
function textsFor(config, locale) {
|
|
207
|
+
return { ...texts[locale], ...config.texts?.[locale] };
|
|
208
|
+
}
|
|
209
|
+
function version(config) {
|
|
210
|
+
let h = 2166136261;
|
|
211
|
+
for (const ch of activeProviders(config).map((p) => p.id).join()) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
|
|
212
|
+
return (h >>> 0).toString(36);
|
|
213
|
+
}
|
|
214
|
+
function runtimeData(config, locale) {
|
|
215
|
+
const active = activeProviders(config);
|
|
216
|
+
const del = (c) => active.filter((p) => p.category === c).flatMap((p) => p.cookies.filter((k) => !k.host).map((k) => k.name));
|
|
217
|
+
const t = textsFor(config, locale);
|
|
218
|
+
return {
|
|
219
|
+
v: version(config),
|
|
220
|
+
name: config.cookie?.name ?? "keksi",
|
|
221
|
+
domain: config.cookie?.domain,
|
|
222
|
+
days: config.cookie?.days ?? 365,
|
|
223
|
+
ga: list(config.ga),
|
|
224
|
+
ads: list(config.googleAds),
|
|
225
|
+
meta: list(config.meta),
|
|
226
|
+
yt: !!config.youtube,
|
|
227
|
+
del: { analytics: del("analytics"), marketing: del("marketing") },
|
|
228
|
+
t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen }
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/styles.ts
|
|
233
|
+
var css = [
|
|
234
|
+
":where(.keksi,.keksi-dialog){--_bg:var(--keksi-bg,#fff);--_fg:var(--keksi-fg,#1b1b1b);--_line:var(--keksi-line,#c8c8c8);--_r:var(--keksi-radius,6px);box-sizing:border-box;background:var(--_bg);color:var(--_fg);font:inherit;font-size:15px;line-height:1.5}",
|
|
235
|
+
":where(.keksi-policy){--_line:var(--keksi-line,#c8c8c8)}",
|
|
236
|
+
":where(.keksi){position:fixed;z-index:2147483000;inset:auto 1rem 1rem;max-width:34rem;max-height:calc(100vh - 2rem);overflow:auto;margin-inline:auto;padding:1.25rem;border:1px solid var(--_line);border-radius:var(--_r);box-shadow:0 4px 24px #0003}",
|
|
237
|
+
":where(.keksi,.keksi-dialog) :where(h2){font-size:1.125rem;margin:0 0 .5rem}",
|
|
238
|
+
":where(.keksi) :where(p,ul){margin:0 0 .75rem}",
|
|
239
|
+
":where(.keksi) :where(ul){padding-left:1.25rem}",
|
|
240
|
+
":where(.keksi,.keksi-dialog) :where(a){color:inherit}",
|
|
241
|
+
":where(.keksi-actions){display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}",
|
|
242
|
+
":where(.keksi-btn){flex:1 1 auto;font:inherit;font-weight:600;padding:.6rem 1rem;border:1px solid var(--_fg);border-radius:var(--_r);background:var(--_bg);color:var(--_fg);cursor:pointer}",
|
|
243
|
+
":where(.keksi-btn:hover){background:var(--_fg);color:var(--_bg)}",
|
|
244
|
+
":where(.keksi,.keksi-dialog,keksi-youtube) :where(button,a,input,summary):focus-visible{outline:3px solid var(--keksi-focus,#2563eb);outline-offset:2px}",
|
|
245
|
+
":where(.keksi-dialog){width:min(40rem,calc(100% - 2rem));max-height:calc(100% - 2rem);padding:0;border:0;border-radius:var(--_r)}",
|
|
246
|
+
":where(.keksi-dialog)::backdrop{background:#0009}",
|
|
247
|
+
":where(.keksi-dialog form){padding:1.25rem}",
|
|
248
|
+
":where(.keksi-head,.keksi-cat-head){display:flex;align-items:center;justify-content:space-between;gap:1rem}",
|
|
249
|
+
":where(.keksi-close){font:inherit;font-size:1.5rem;line-height:1;padding:.25rem .5rem;border:0;background:none;color:inherit;cursor:pointer}",
|
|
250
|
+
":where(.keksi-cat){padding:.75rem 0;border-top:1px solid var(--_line)}",
|
|
251
|
+
":where(.keksi-cat-head) :where(h2,h3,h4,h5){font-size:1rem;margin:0}",
|
|
252
|
+
":where(.keksi-cat) :where(p){margin:.25rem 0}",
|
|
253
|
+
":where(.keksi-cat input[role=switch]){appearance:none;position:relative;flex:none;width:2.75rem;height:1.5rem;margin:0;border-radius:1rem;background:var(--_line);cursor:pointer}",
|
|
254
|
+
':where(.keksi-cat input[role=switch])::before{content:"";position:absolute;top:.2rem;left:.2rem;width:1.1rem;height:1.1rem;border-radius:50%;background:#fff;transition:transform .15s}',
|
|
255
|
+
":where(.keksi-cat input[role=switch]:checked){background:var(--_fg)}",
|
|
256
|
+
":where(.keksi-cat input[role=switch]:checked)::before{transform:translateX(1.25rem)}",
|
|
257
|
+
":where(.keksi-cat summary){margin-top:.5rem;font-weight:600;cursor:pointer}",
|
|
258
|
+
":where(.keksi-provider){margin:.75rem 0}",
|
|
259
|
+
":where(.keksi-provider) :where(h3,h4,h5,h6){font-size:.9375rem;margin:0}",
|
|
260
|
+
":where(.keksi-cat table){width:100%;border-collapse:collapse;font-size:.875rem;margin-top:.25rem}",
|
|
261
|
+
":where(.keksi-cat) :where(th,td){padding:.25rem .75rem .25rem 0;border-bottom:1px solid var(--_line);text-align:left;vertical-align:top}",
|
|
262
|
+
":where(keksi-youtube){display:block;aspect-ratio:16/9;background:#111;color:#fff}",
|
|
263
|
+
":where(keksi-youtube iframe){display:block;width:100%;height:100%;border:0}",
|
|
264
|
+
":where(.keksi-yt){display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;height:100%;padding:1rem;box-sizing:border-box;text-align:center}",
|
|
265
|
+
":where(.keksi-yt) :where(p){max-width:32rem;margin:0}",
|
|
266
|
+
":where(.keksi-yt) :where(a){color:inherit}",
|
|
267
|
+
":where(.keksi-yt button){font:inherit;font-weight:600;padding:.6rem 1.25rem;border:0;border-radius:6px;background:#fff;color:#111;cursor:pointer}"
|
|
268
|
+
].join("");
|
|
269
|
+
|
|
270
|
+
// src/render.ts
|
|
271
|
+
var e = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
272
|
+
function renderKeksi(config, { lang = "fi", nonce } = {}) {
|
|
273
|
+
const cats = categoriesOf(config);
|
|
274
|
+
if (!cats.length) return "";
|
|
275
|
+
const t = textsFor(config, lang);
|
|
276
|
+
const active = activeProviders(config);
|
|
277
|
+
const policy = typeof config.policyUrl === "string" ? config.policyUrl : config.policyUrl?.[lang];
|
|
278
|
+
const n = nonce ? ` nonce="${e(nonce)}"` : "";
|
|
279
|
+
const btn = (action, label, type = "button") => `<button type="${type}" class="keksi-btn" data-keksi="${action}">${e(label)}</button>`;
|
|
280
|
+
const uses = cats.map((c) => `<li><strong>${e(t[c])}:</strong> ${e(active.filter((p) => p.category === c).map((p) => p.name).join(", "))}</li>`).join("");
|
|
281
|
+
const json = JSON.stringify(runtimeData(config, lang)).replace(/</g, "\\u003c");
|
|
282
|
+
return `<style${n}>${css}</style><section id="keksi-banner" class="keksi" aria-labelledby="keksi-title" hidden><h2 id="keksi-title">${e(t.title)}</h2><p>${e(t.intro)}</p><ul>${uses}</ul><p>${e(t.transfer)}${policy ? ` <a href="${e(policy)}">${e(t.more)}</a>` : ""}</p><div class="keksi-actions">${btn("accept", t.accept)}${btn("reject", t.reject)}<button type="button" class="keksi-btn" data-keksi-open>${e(t.settings)}</button></div></section><dialog id="keksi-dialog" class="keksi-dialog" aria-labelledby="keksi-dialog-title"><form method="dialog"><div class="keksi-head"><h2 id="keksi-dialog-title">${e(t.settingsTitle)}</h2><button class="keksi-close" aria-label="${e(t.close)}">\xD7</button></div>` + sections(config, lang, 3, true) + `<div class="keksi-actions">${btn("save", t.save, "submit")}${btn("accept", t.accept, "submit")}${btn("reject", t.reject, "submit")}</div></form></dialog><script type="application/json" id="keksi-data">${json}</script><script${n}>${'"use strict";(()=>{var v=["analytics","marketing"],C={analytics:!1,marketing:!1};function g(...a){return arguments}function q(a,o=window){if(typeof o.keksi?.sync=="function")return o.keksi.sync(),o.keksi;let r=o.document,h=e=>r.getElementById(e),l=w(),A=!1,E=!1,b=new Set,$=g("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied"});if(a.ga.length||a.ads.length){let e=o.dataLayer??(o.dataLayer=[]);o.gtag=function(){e.push(arguments)},e.push($)}if(a.meta.length){let e=function(){e.callMethod?e.callMethod.apply(e,[...arguments]):e.queue.push(arguments)};Object.assign(e,{push:e,loaded:!0,version:"2.0",queue:[]}),o.fbq=o._fbq=e}function w(){let e=r.cookie.split("; ").find(s=>s.startsWith(`${a.name}=`))?.slice(a.name.length+1),[n,,t,i]=e?.split(".")??[];return n!==a.v||!t||!i?null:{id:t,c:{analytics:i.includes("a"),marketing:i.includes("m")}}}function T(e){let n=l?.id??Array.from(o.crypto.getRandomValues(new Uint8Array(8)),i=>i.toString(16).padStart(2,"0")).join(""),t=(e.analytics?"a":"")+(e.marketing?"m":"")||"-";return r.cookie=`${a.name}=${a.v}.${Date.now().toString(36)}.${n}.${t}; Path=/; Max-Age=${a.days*86400}; SameSite=Lax`+(a.domain?`; Domain=${a.domain}`:"")+(o.location.protocol==="https:"?"; Secure":""),{id:n,c:e}}function S(e){let n=r.createElement("script");n.async=!0,n.src=e,r.head.append(n)}function _(e){let n=[...e.analytics?a.ga:[],...e.marketing?a.ads:[]].filter(t=>!b.has(t));if(n.length){let t=e.marketing?"granted":"denied",i=[g("consent","update",{analytics_storage:e.analytics?"granted":"denied",ad_storage:t,ad_user_data:t,ad_personalization:t})];b.size||i.push(g("js",new Date));for(let s of n)i.push(g("config",s)),b.add(s);if(A)for(let s of i)o.dataLayer.push(s);else{let s=o.dataLayer;s.splice(s.indexOf($)+1,0,...i),S(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(n[0])}`),A=!0}}if(e.marketing&&a.meta.length&&!E&&(E=!0,o.fbq.queue.unshift(...a.meta.map(t=>g("init",t)),g("track","PageView")),S("https://connect.facebook.net/en_US/fbevents.js")),e.marketing)for(let t of r.querySelectorAll("keksi-youtube"))t.show(!0)}function M(e){let n=e.flatMap(c=>a.del[c]),t=c=>n.some(u=>u.endsWith("*")?c.startsWith(u.slice(0,-1)):c===u),i=o.location.hostname.split("."),s=["",...i.map((c,u)=>`; Domain=${i.slice(u).join(".")}`)];for(let c of r.cookie.split("; ")){let u=c.split("=")[0];if(t(u))for(let y of s)r.cookie=`${u}=; Max-Age=0; Path=/${y}`}try{for(let c of Object.keys(o.localStorage))t(c)&&o.localStorage.removeItem(c)}catch{}}function f(e){let n=l?.c;l=T(e),k(),r.dispatchEvent(new o.CustomEvent("keksi:change",{detail:{...e}})),M(v.filter(t=>!e[t])),v.some(t=>n?.[t]&&!e[t])?o.location.reload():_(e)}function k(){let e=h("keksi-banner");e&&(e.hidden=!!l)}function x(){let e=h("keksi-dialog");if(e){for(let n of v){let t=e.querySelector(`[name=${n}]`);t&&(t.checked=!!l?.c[n])}e.showModal()}}r.addEventListener("click",e=>{let n=e.target.closest?.("[data-keksi],[data-keksi-open]");if(!n)return;if(n.hasAttribute("data-keksi-open"))return e.preventDefault(),x();let t=n.getAttribute("data-keksi");if(t==="accept")f({analytics:!0,marketing:!0});else if(t==="reject")f(C);else if(t==="save"){let i=s=>!!h("keksi-dialog")?.querySelector(`[name=${s}]`)?.checked;f({analytics:i("analytics"),marketing:i("marketing")})}}),r.addEventListener("astro:after-swap",k);class j extends o.HTMLElement{connectedCallback(){this.show(!!l?.c.marketing)}show(n,t=!1){let i=encodeURIComponent(this.getAttribute("video")??""),s=this.getAttribute("label");if(n){if(this.querySelector("iframe"))return;let d=r.createElement("iframe");d.src=`https://www.youtube-nocookie.com/embed/${i}${t?"?autoplay=1":""}`,d.title=s??"YouTube",d.allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",d.allowFullscreen=!0,d.referrerPolicy="strict-origin-when-cross-origin",this.replaceChildren(d),t&&d.focus();return}let c=r.createElement("div");c.className="keksi-yt";let u=this.getAttribute("poster");u&&(c.style.background=`linear-gradient(#000a,#000a),center/cover url(${JSON.stringify(u)})`);let y=r.createElement("p");y.textContent=a.t.ytText;let m=r.createElement("button");m.type="button",m.textContent=a.t.ytButton,s&&m.setAttribute("aria-label",`${a.t.ytButton}: ${s}`),m.onclick=()=>this.show(!0,!0);let p=r.createElement("a");p.href=`https://www.youtube.com/watch?v=${i}`,p.target="_blank",p.rel="noopener",p.textContent=a.t.ytOpen,c.append(y,m,p),this.replaceChildren(c)}}a.yt&&!o.customElements.get("keksi-youtube")&&o.customElements.define("keksi-youtube",j);let L={get consent(){return l?{...l.c}:null},open:x,save:e=>f({...l?.c??C,...e}),acceptAll:()=>f({analytics:!0,marketing:!0}),rejectAll:()=>f(C),sync:k};return o.keksi=L,k(),l&&_(l.c),L}q(JSON.parse(document.getElementById("keksi-data").textContent));})();'}</script>`;
|
|
283
|
+
}
|
|
284
|
+
function cookieTable(config, { lang = "fi", headingLevel = 2 } = {}) {
|
|
285
|
+
return `<div class="keksi-policy">${sections(config, lang, headingLevel, false)}</div>`;
|
|
286
|
+
}
|
|
287
|
+
function sections(config, lang, h, dialog) {
|
|
288
|
+
const t = textsFor(config, lang);
|
|
289
|
+
const active = activeProviders(config);
|
|
290
|
+
const { name, days } = runtimeData(config, lang);
|
|
291
|
+
const site = {
|
|
292
|
+
name: t.thisSite,
|
|
293
|
+
purpose: t.consentPurpose,
|
|
294
|
+
cookies: [{ name, duration: days % 365 ? `${days}d` : `${days / 365}y` }]
|
|
295
|
+
};
|
|
296
|
+
return ["necessary", ...categoriesOf(config)].map((c) => {
|
|
297
|
+
const items = c === "necessary" ? [site] : active.filter((p) => p.category === c).map((p) => ({ ...p, purpose: p.purpose[lang] }));
|
|
298
|
+
const head = c === "necessary" ? `<h${h}>${e(t.necessary)}</h${h}><span>${e(t.alwaysOn)}</span>` : dialog ? `<h${h}><label for="keksi-c-${c}">${e(t[c])}</label></h${h}><input type="checkbox" role="switch" id="keksi-c-${c}" name="${c}" aria-describedby="keksi-i-${c}">` : `<h${h}>${e(t[c])}</h${h}>`;
|
|
299
|
+
const list2 = items.map(
|
|
300
|
+
(p) => `<div class="keksi-provider"><h${h + 1}>${e(p.name)}</h${h + 1}><p>${e(p.purpose)}</p>` + (p.company && p.privacyPolicy ? `<p>${e(p.company)} \xB7 <a href="${e(p.privacyPolicy)}" target="_blank" rel="noopener">${e(t.privacyPolicy)}</a></p>` + (p.transfer ? `<p>${e(t.dpf)}</p>` : "") : "") + table(p.cookies, t) + `</div>`
|
|
301
|
+
).join("");
|
|
302
|
+
const body = dialog ? `<details><summary>${e(t.providers)} (${items.length})</summary>${list2}</details>` : list2;
|
|
303
|
+
return `<section class="keksi-cat"><div class="keksi-cat-head">${head}</div><p id="keksi-i-${c}">${e(t[`${c}Info`])}</p>${body}</section>`;
|
|
304
|
+
}).join("");
|
|
305
|
+
}
|
|
306
|
+
function table(cookies, t) {
|
|
307
|
+
const rows = cookies.map(
|
|
308
|
+
(c) => `<tr><td><code>${e(c.name)}</code>${c.storage ? ` (${c.storage})` : ""}</td><td>${e(duration(c.duration, t))}</td><td>${e(c.host ?? t.thisSite)}</td></tr>`
|
|
309
|
+
).join("");
|
|
310
|
+
return `<table><thead><tr><th scope="col">${e(t.name)}</th><th scope="col">${e(t.duration)}</th><th scope="col">${e(t.setBy)}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
311
|
+
}
|
|
312
|
+
function duration(d, t) {
|
|
313
|
+
if (d === "session" || d === "persistent") return t[d];
|
|
314
|
+
const [, n, unit] = d.match(/^(\d+)(y|mo|d|min)$/);
|
|
315
|
+
const [one, many] = t[unit].split("|");
|
|
316
|
+
return `${n} ${n === "1" ? one : many}`;
|
|
317
|
+
}
|
|
318
|
+
export {
|
|
319
|
+
cookieTable,
|
|
320
|
+
defineConfig,
|
|
321
|
+
providers,
|
|
322
|
+
renderKeksi,
|
|
323
|
+
version
|
|
324
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import type { Consent, KeksiConfig } from './types.js';
|
|
3
|
+
export interface TrackingOptions {
|
|
4
|
+
/** Cookies and localStorage keys your site sets without consent, e.g. a session cookie. */
|
|
5
|
+
allow?: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Records requests to Google, Meta and YouTube from now on. `check()` throws when any were made or when the page
|
|
9
|
+
* stored anything besides the consent cookie and `allow`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function watchTracking(page: Page, config: KeksiConfig, { allow }?: TrackingOptions): {
|
|
12
|
+
requests: string[];
|
|
13
|
+
check(): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
/** Consent cookie for `context.addCookies`, to skip the banner in other tests. */
|
|
16
|
+
export declare function consentCookie(config: KeksiConfig, url: string, consent?: Partial<Consent>): {
|
|
17
|
+
name: string;
|
|
18
|
+
value: string;
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// src/i18n.ts
|
|
2
|
+
var fi = {
|
|
3
|
+
title: "Ev\xE4steet",
|
|
4
|
+
intro: "K\xE4yt\xE4mme v\xE4ltt\xE4m\xE4tt\xF6mien ev\xE4steiden lis\xE4ksi seuraavia palveluita vain, jos annat siihen luvan:",
|
|
5
|
+
transfer: "Palveluntarjoajat voivat siirt\xE4\xE4 tietoja Yhdysvaltoihin. Voit muuttaa valintaasi milloin tahansa.",
|
|
6
|
+
more: "Lue lis\xE4\xE4",
|
|
7
|
+
accept: "Hyv\xE4ksy kaikki",
|
|
8
|
+
reject: "Hylk\xE4\xE4 kaikki",
|
|
9
|
+
settings: "Asetukset",
|
|
10
|
+
settingsTitle: "Ev\xE4steasetukset",
|
|
11
|
+
save: "Tallenna valinnat",
|
|
12
|
+
close: "Sulje",
|
|
13
|
+
alwaysOn: "Aina k\xE4yt\xF6ss\xE4",
|
|
14
|
+
necessary: "V\xE4ltt\xE4m\xE4tt\xF6m\xE4t",
|
|
15
|
+
necessaryInfo: "Sivuston toiminnan kannalta v\xE4ltt\xE4m\xE4tt\xF6m\xE4t, kuten ev\xE4stevalintasi tallentaminen. Eiv\xE4t vaadi suostumusta.",
|
|
16
|
+
analytics: "Analytiikka",
|
|
17
|
+
analyticsInfo: "Mittaamme sivuston k\xE4ytt\xF6\xE4, jotta voimme kehitt\xE4\xE4 sit\xE4.",
|
|
18
|
+
marketing: "Markkinointi",
|
|
19
|
+
marketingInfo: "Mainonnan kohdentaminen ja tehon mittaaminen sek\xE4 upotetut sis\xE4ll\xF6t, kuten videot.",
|
|
20
|
+
providers: "Palveluntarjoajat",
|
|
21
|
+
thisSite: "T\xE4m\xE4 sivusto",
|
|
22
|
+
consentPurpose: "Tallentaa ev\xE4stevalintasi.",
|
|
23
|
+
privacyPolicy: "Tietosuojaseloste",
|
|
24
|
+
dpf: "Tietoja voidaan siirt\xE4\xE4 Yhdysvaltoihin EU:n ja Yhdysvaltojen v\xE4lisen tietosuojakehyksen (DPF) perusteella.",
|
|
25
|
+
name: "Nimi",
|
|
26
|
+
duration: "Kesto",
|
|
27
|
+
setBy: "Asettaja",
|
|
28
|
+
session: "istunto",
|
|
29
|
+
persistent: "kunnes poistetaan",
|
|
30
|
+
y: "vuosi|vuotta",
|
|
31
|
+
mo: "kuukausi|kuukautta",
|
|
32
|
+
d: "p\xE4iv\xE4|p\xE4iv\xE4\xE4",
|
|
33
|
+
min: "minuutti|minuuttia",
|
|
34
|
+
ytText: "Video ladataan YouTubesta. YouTube (Google) asettaa ev\xE4steit\xE4 ja voi siirt\xE4\xE4 tietoja Yhdysvaltoihin.",
|
|
35
|
+
ytButton: "N\xE4yt\xE4 video",
|
|
36
|
+
ytOpen: "Avaa YouTubessa"
|
|
37
|
+
};
|
|
38
|
+
var sv = {
|
|
39
|
+
title: "Kakor",
|
|
40
|
+
intro: "Ut\xF6ver n\xF6dv\xE4ndiga kakor anv\xE4nder vi f\xF6ljande tj\xE4nster endast om du ger ditt samtycke:",
|
|
41
|
+
transfer: "Tj\xE4nsteleverant\xF6rerna kan \xF6verf\xF6ra uppgifter till USA. Du kan \xE4ndra ditt val n\xE4r som helst.",
|
|
42
|
+
more: "L\xE4s mer",
|
|
43
|
+
accept: "Godk\xE4nn alla",
|
|
44
|
+
reject: "Avvisa alla",
|
|
45
|
+
settings: "Inst\xE4llningar",
|
|
46
|
+
settingsTitle: "Inst\xE4llningar f\xF6r kakor",
|
|
47
|
+
save: "Spara val",
|
|
48
|
+
close: "St\xE4ng",
|
|
49
|
+
alwaysOn: "Alltid p\xE5",
|
|
50
|
+
necessary: "N\xF6dv\xE4ndiga",
|
|
51
|
+
necessaryInfo: "N\xF6dv\xE4ndiga f\xF6r att webbplatsen ska fungera, till exempel f\xF6r att spara ditt val. Kr\xE4ver inget samtycke.",
|
|
52
|
+
analytics: "Analys",
|
|
53
|
+
analyticsInfo: "Vi m\xE4ter hur webbplatsen anv\xE4nds f\xF6r att kunna utveckla den.",
|
|
54
|
+
marketing: "Marknadsf\xF6ring",
|
|
55
|
+
marketingInfo: "Riktad annonsering och m\xE4tning av annonsernas effekt samt inb\xE4ddat inneh\xE5ll, till exempel videor.",
|
|
56
|
+
providers: "Tj\xE4nsteleverant\xF6rer",
|
|
57
|
+
thisSite: "Denna webbplats",
|
|
58
|
+
consentPurpose: "Sparar ditt val av kakor.",
|
|
59
|
+
privacyPolicy: "Dataskyddsbeskrivning",
|
|
60
|
+
dpf: "Uppgifter kan \xF6verf\xF6ras till USA med st\xF6d av ramverket f\xF6r dataskydd mellan EU och USA (DPF).",
|
|
61
|
+
name: "Namn",
|
|
62
|
+
duration: "Varaktighet",
|
|
63
|
+
setBy: "S\xE4tts av",
|
|
64
|
+
session: "session",
|
|
65
|
+
persistent: "tills den raderas",
|
|
66
|
+
y: "\xE5r|\xE5r",
|
|
67
|
+
mo: "m\xE5nad|m\xE5nader",
|
|
68
|
+
d: "dag|dagar",
|
|
69
|
+
min: "minut|minuter",
|
|
70
|
+
ytText: "Videon laddas fr\xE5n YouTube. YouTube (Google) s\xE4tter kakor och kan \xF6verf\xF6ra uppgifter till USA.",
|
|
71
|
+
ytButton: "Visa video",
|
|
72
|
+
ytOpen: "\xD6ppna p\xE5 YouTube"
|
|
73
|
+
};
|
|
74
|
+
var en = {
|
|
75
|
+
title: "Cookies",
|
|
76
|
+
intro: "Besides necessary cookies, we use the following services only if you allow it:",
|
|
77
|
+
transfer: "The providers may transfer data to the United States. You can change your choice at any time.",
|
|
78
|
+
more: "Read more",
|
|
79
|
+
accept: "Accept all",
|
|
80
|
+
reject: "Reject all",
|
|
81
|
+
settings: "Settings",
|
|
82
|
+
settingsTitle: "Cookie settings",
|
|
83
|
+
save: "Save choices",
|
|
84
|
+
close: "Close",
|
|
85
|
+
alwaysOn: "Always on",
|
|
86
|
+
necessary: "Necessary",
|
|
87
|
+
necessaryInfo: "Needed for the site to work, such as storing your cookie choice. No consent needed.",
|
|
88
|
+
analytics: "Analytics",
|
|
89
|
+
analyticsInfo: "We measure how the site is used so we can improve it.",
|
|
90
|
+
marketing: "Marketing",
|
|
91
|
+
marketingInfo: "Ad targeting and measurement, and embedded content such as videos.",
|
|
92
|
+
providers: "Providers",
|
|
93
|
+
thisSite: "This site",
|
|
94
|
+
consentPurpose: "Stores your cookie choice.",
|
|
95
|
+
privacyPolicy: "Privacy policy",
|
|
96
|
+
dpf: "Data may be transferred to the United States under the EU\u2013US Data Privacy Framework (DPF).",
|
|
97
|
+
name: "Name",
|
|
98
|
+
duration: "Duration",
|
|
99
|
+
setBy: "Set by",
|
|
100
|
+
session: "session",
|
|
101
|
+
persistent: "until deleted",
|
|
102
|
+
y: "year|years",
|
|
103
|
+
mo: "month|months",
|
|
104
|
+
d: "day|days",
|
|
105
|
+
min: "minute|minutes",
|
|
106
|
+
ytText: "The video loads from YouTube. YouTube (Google) sets cookies and may transfer data to the United States.",
|
|
107
|
+
ytButton: "Show video",
|
|
108
|
+
ytOpen: "Open on YouTube"
|
|
109
|
+
};
|
|
110
|
+
var texts = { fi, sv, en };
|
|
111
|
+
|
|
112
|
+
// src/providers.ts
|
|
113
|
+
var GOOGLE = { company: "Google Ireland Limited", privacyPolicy: "https://policies.google.com/privacy", transfer: "dpf" };
|
|
114
|
+
var META = { company: "Meta Platforms Ireland Limited", privacyPolicy: "https://www.facebook.com/privacy/policy/", transfer: "dpf" };
|
|
115
|
+
var providers = [
|
|
116
|
+
{
|
|
117
|
+
id: "ga",
|
|
118
|
+
name: "Google Analytics",
|
|
119
|
+
...GOOGLE,
|
|
120
|
+
category: "analytics",
|
|
121
|
+
purpose: {
|
|
122
|
+
fi: "K\xE4vij\xE4tilastot: k\xE4vij\xE4m\xE4\xE4r\xE4t, liikenteen l\xE4hteet ja sivujen k\xE4ytt\xF6.",
|
|
123
|
+
sv: "Bes\xF6ksstatistik: antal bes\xF6kare, trafikk\xE4llor och hur sidorna anv\xE4nds.",
|
|
124
|
+
en: "Visitor statistics: visitor numbers, traffic sources and page usage."
|
|
125
|
+
},
|
|
126
|
+
cookies: [
|
|
127
|
+
{ name: "_ga", duration: "2y" },
|
|
128
|
+
{ name: "_ga_*", duration: "2y" }
|
|
129
|
+
],
|
|
130
|
+
hosts: ["google-analytics.com", "analytics.google.com", "googletagmanager.com"]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "googleAds",
|
|
134
|
+
name: "Google Ads",
|
|
135
|
+
...GOOGLE,
|
|
136
|
+
category: "marketing",
|
|
137
|
+
purpose: {
|
|
138
|
+
fi: "Mainonnan tehon mittaaminen (konversiot) ja mainosten kohdentaminen sivuston k\xE4vij\xF6ille.",
|
|
139
|
+
sv: "M\xE4tning av annonsernas effekt (konverteringar) och riktad annonsering till bes\xF6kare.",
|
|
140
|
+
en: "Measuring ad performance (conversions) and showing ads to site visitors."
|
|
141
|
+
},
|
|
142
|
+
cookies: [
|
|
143
|
+
{ name: "_gcl_au", duration: "90d" },
|
|
144
|
+
{ name: "_gcl_aw", duration: "90d" },
|
|
145
|
+
{ name: "_gcl_dc", duration: "90d" },
|
|
146
|
+
{ name: "_gcl_gs", duration: "90d" },
|
|
147
|
+
{ name: "_gac_*", duration: "90d" },
|
|
148
|
+
{ name: "_gcl_ls", duration: "persistent", storage: "localStorage" },
|
|
149
|
+
{ name: "IDE", duration: "13mo", host: "doubleclick.net" },
|
|
150
|
+
{ name: "DSID", duration: "14d", host: "doubleclick.net" },
|
|
151
|
+
{ name: "test_cookie", duration: "15min", host: "doubleclick.net" }
|
|
152
|
+
],
|
|
153
|
+
hosts: ["googletagmanager.com", "googleadservices.com", "doubleclick.net", "googlesyndication.com"]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "meta",
|
|
157
|
+
name: "Meta Pixel",
|
|
158
|
+
...META,
|
|
159
|
+
category: "marketing",
|
|
160
|
+
purpose: {
|
|
161
|
+
fi: "Mainonnan tehon mittaaminen ja mainosten kohdentaminen Facebookissa ja Instagramissa.",
|
|
162
|
+
sv: "M\xE4tning av annonsernas effekt och riktad annonsering p\xE5 Facebook och Instagram.",
|
|
163
|
+
en: "Measuring ad performance and targeting ads on Facebook and Instagram."
|
|
164
|
+
},
|
|
165
|
+
cookies: [
|
|
166
|
+
{ name: "_fbp", duration: "90d" },
|
|
167
|
+
{ name: "_fbc", duration: "90d" },
|
|
168
|
+
{ name: "fr", duration: "90d", host: "facebook.com" }
|
|
169
|
+
],
|
|
170
|
+
hosts: ["facebook.net", "facebook.com"]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "youtube",
|
|
174
|
+
name: "YouTube",
|
|
175
|
+
...GOOGLE,
|
|
176
|
+
category: "marketing",
|
|
177
|
+
purpose: {
|
|
178
|
+
fi: "Upotettujen videoiden toistaminen. YouTube voi k\xE4ytt\xE4\xE4 tietoja mainontaan.",
|
|
179
|
+
sv: "Uppspelning av inb\xE4ddade videor. YouTube kan anv\xE4nda uppgifterna f\xF6r annonsering.",
|
|
180
|
+
en: "Playing embedded videos. YouTube may use the data for advertising."
|
|
181
|
+
},
|
|
182
|
+
cookies: [
|
|
183
|
+
{ name: "YSC", duration: "session", host: "youtube.com" },
|
|
184
|
+
{ name: "VISITOR_INFO1_LIVE", duration: "6mo", host: "youtube.com" },
|
|
185
|
+
{ name: "VISITOR_PRIVACY_METADATA", duration: "6mo", host: "youtube.com" },
|
|
186
|
+
{ name: "__Secure-YNID", duration: "6mo", host: "youtube.com" },
|
|
187
|
+
{ name: "__Secure-ROLLOUT_TOKEN", duration: "6mo", host: "youtube.com" },
|
|
188
|
+
{ name: "PREF", duration: "8mo", host: "youtube.com" },
|
|
189
|
+
{ name: "yt-remote-*, ytidb::*, yt-player-*", duration: "persistent", host: "youtube-nocookie.com", storage: "localStorage" }
|
|
190
|
+
],
|
|
191
|
+
hosts: ["youtube.com", "youtube-nocookie.com", "ytimg.com", "googlevideo.com"]
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// src/config.ts
|
|
196
|
+
var list = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
197
|
+
function activeProviders(config) {
|
|
198
|
+
return providers.filter((p) => p.id === "youtube" ? config.youtube : list(config[p.id]).length);
|
|
199
|
+
}
|
|
200
|
+
function textsFor(config, locale) {
|
|
201
|
+
return { ...texts[locale], ...config.texts?.[locale] };
|
|
202
|
+
}
|
|
203
|
+
function version(config) {
|
|
204
|
+
let h = 2166136261;
|
|
205
|
+
for (const ch of activeProviders(config).map((p) => p.id).join()) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
|
|
206
|
+
return (h >>> 0).toString(36);
|
|
207
|
+
}
|
|
208
|
+
function runtimeData(config, locale) {
|
|
209
|
+
const active = activeProviders(config);
|
|
210
|
+
const del = (c) => active.filter((p) => p.category === c).flatMap((p) => p.cookies.filter((k) => !k.host).map((k) => k.name));
|
|
211
|
+
const t = textsFor(config, locale);
|
|
212
|
+
return {
|
|
213
|
+
v: version(config),
|
|
214
|
+
name: config.cookie?.name ?? "keksi",
|
|
215
|
+
domain: config.cookie?.domain,
|
|
216
|
+
days: config.cookie?.days ?? 365,
|
|
217
|
+
ga: list(config.ga),
|
|
218
|
+
ads: list(config.googleAds),
|
|
219
|
+
meta: list(config.meta),
|
|
220
|
+
yt: !!config.youtube,
|
|
221
|
+
del: { analytics: del("analytics"), marketing: del("marketing") },
|
|
222
|
+
t: { ytText: t.ytText, ytButton: t.ytButton, ytOpen: t.ytOpen }
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/playwright.ts
|
|
227
|
+
var trackerHosts = [...new Set(providers.flatMap((p) => p.hosts))];
|
|
228
|
+
function watchTracking(page, config, { allow = [] } = {}) {
|
|
229
|
+
const requests = [];
|
|
230
|
+
page.on("request", (r) => {
|
|
231
|
+
const host = new URL(r.url()).hostname;
|
|
232
|
+
if (trackerHosts.some((t) => host === t || host.endsWith(`.${t}`))) requests.push(r.url());
|
|
233
|
+
});
|
|
234
|
+
const allowed = /* @__PURE__ */ new Set([runtimeData(config, "fi").name, ...allow]);
|
|
235
|
+
return {
|
|
236
|
+
requests,
|
|
237
|
+
async check() {
|
|
238
|
+
const cookies = (await page.context().cookies()).map((c) => `${c.name} (${c.domain})`);
|
|
239
|
+
const storage = await page.evaluate(() => Object.keys(localStorage)).catch(() => []);
|
|
240
|
+
const stored = [...cookies.filter((c) => !allowed.has(c.split(" ")[0])), ...storage.filter((k) => !allowed.has(k))];
|
|
241
|
+
const problems = [
|
|
242
|
+
...requests.map((u) => `request: ${u}`),
|
|
243
|
+
...stored.map((s) => `stored: ${s}`)
|
|
244
|
+
];
|
|
245
|
+
if (problems.length) throw new Error(`Tracking without consent:
|
|
246
|
+
${problems.join("\n ")}`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function consentCookie(config, url, consent = {}) {
|
|
251
|
+
const d = runtimeData(config, "fi");
|
|
252
|
+
const flags = (consent.analytics ? "a" : "") + (consent.marketing ? "m" : "") || "-";
|
|
253
|
+
return { name: d.name, value: `${d.v}.${Date.now().toString(36)}.test.${flags}`, url };
|
|
254
|
+
}
|
|
255
|
+
export {
|
|
256
|
+
consentCookie,
|
|
257
|
+
watchTracking
|
|
258
|
+
};
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Texts } from './i18n.js';
|
|
2
|
+
import type { Duration, KeksiConfig, Locale } from './types.js';
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
lang?: Locale;
|
|
5
|
+
/** For a CSP with nonces; applied to the inline `<style>` and `<script>`. */
|
|
6
|
+
nonce?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Banner, settings dialog and runtime. Place it right after `<body>`. Empty when no provider needs consent. */
|
|
9
|
+
export declare function renderKeksi(config: KeksiConfig, { lang, nonce }?: RenderOptions): string;
|
|
10
|
+
export interface CookieTableOptions {
|
|
11
|
+
lang?: Locale;
|
|
12
|
+
headingLevel?: 2 | 3 | 4;
|
|
13
|
+
}
|
|
14
|
+
/** Cookie policy section generated from the same data as the banner. */
|
|
15
|
+
export declare function cookieTable(config: KeksiConfig, { lang, headingLevel }?: CookieTableOptions): string;
|
|
16
|
+
export declare function duration(d: Duration, t: Texts): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Consent, RuntimeData } from './types.js';
|
|
2
|
+
export interface KeksiApi {
|
|
3
|
+
/** `null` until the visitor decides. */
|
|
4
|
+
readonly consent: Consent | null;
|
|
5
|
+
open(): void;
|
|
6
|
+
/** Unlisted categories keep their current value. */
|
|
7
|
+
save(consent: Partial<Consent>): void;
|
|
8
|
+
acceptAll(): void;
|
|
9
|
+
rejectAll(): void;
|
|
10
|
+
/** Re-reads the banner from the DOM, e.g. after a client-side page swap. */
|
|
11
|
+
sync(): void;
|
|
12
|
+
}
|
|
13
|
+
type Queue = ((...a: unknown[]) => void) & {
|
|
14
|
+
queue: IArguments[];
|
|
15
|
+
callMethod?: (...a: unknown[]) => void;
|
|
16
|
+
};
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
keksi?: KeksiApi;
|
|
20
|
+
dataLayer?: unknown[];
|
|
21
|
+
gtag?: (...a: unknown[]) => void;
|
|
22
|
+
fbq?: Queue;
|
|
23
|
+
_fbq?: Queue;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export declare function boot(d: RuntimeData, w?: Window & typeof globalThis): KeksiApi;
|
|
27
|
+
export {};
|
package/dist/styles.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const css: string;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Texts } from './i18n.js';
|
|
2
|
+
export type Locale = 'fi' | 'sv' | 'en';
|
|
3
|
+
export type Category = 'analytics' | 'marketing';
|
|
4
|
+
export type ProviderId = 'ga' | 'googleAds' | 'meta' | 'youtube';
|
|
5
|
+
export type Consent = Record<Category, boolean>;
|
|
6
|
+
export interface KeksiConfig {
|
|
7
|
+
/** GA4 measurement ids, e.g. `G-XXXXXXX`. */
|
|
8
|
+
ga?: string | string[];
|
|
9
|
+
/** Google Ads tag ids, e.g. `AW-123456789`. */
|
|
10
|
+
googleAds?: string | string[];
|
|
11
|
+
/** Meta Pixel ids. */
|
|
12
|
+
meta?: string | string[];
|
|
13
|
+
/** Enables `<keksi-youtube>` embeds. */
|
|
14
|
+
youtube?: boolean;
|
|
15
|
+
/** Cookie policy page, linked from the banner. */
|
|
16
|
+
policyUrl?: string | Partial<Record<Locale, string>>;
|
|
17
|
+
cookie?: {
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Share consent across subdomains, e.g. `example.fi`. */
|
|
20
|
+
domain?: string;
|
|
21
|
+
days?: number;
|
|
22
|
+
};
|
|
23
|
+
texts?: Partial<Record<Locale, Partial<Texts>>>;
|
|
24
|
+
}
|
|
25
|
+
/** `session`, `persistent`, or a number with unit: `2y`, `13mo`, `90d`, `15min`. */
|
|
26
|
+
export type Duration = 'session' | 'persistent' | `${number}${'y' | 'mo' | 'd' | 'min'}`;
|
|
27
|
+
export interface CookieInfo {
|
|
28
|
+
name: string;
|
|
29
|
+
duration: Duration;
|
|
30
|
+
/** Third-party host that sets it. Omitted = set on this site, deleted on withdrawal. */
|
|
31
|
+
host?: string;
|
|
32
|
+
storage?: 'localStorage';
|
|
33
|
+
}
|
|
34
|
+
export interface Provider {
|
|
35
|
+
id: ProviderId;
|
|
36
|
+
name: string;
|
|
37
|
+
category: Category;
|
|
38
|
+
company: string;
|
|
39
|
+
privacyPolicy: string;
|
|
40
|
+
purpose: Record<Locale, string>;
|
|
41
|
+
/** Transfer outside the EU and its legal basis. */
|
|
42
|
+
transfer?: 'dpf';
|
|
43
|
+
cookies: CookieInfo[];
|
|
44
|
+
/** Hosts it contacts, for the Playwright check. */
|
|
45
|
+
hosts: string[];
|
|
46
|
+
}
|
|
47
|
+
/** What the inline runtime needs, serialized into the page. */
|
|
48
|
+
export interface RuntimeData {
|
|
49
|
+
v: string;
|
|
50
|
+
name: string;
|
|
51
|
+
domain?: string;
|
|
52
|
+
days: number;
|
|
53
|
+
ga: string[];
|
|
54
|
+
ads: string[];
|
|
55
|
+
meta: string[];
|
|
56
|
+
yt: boolean;
|
|
57
|
+
/** First-party cookie and localStorage names per category, `*` suffix = prefix. */
|
|
58
|
+
del: Record<Category, string[]>;
|
|
59
|
+
t: Pick<Texts, 'ytText' | 'ytButton' | 'ytOpen'>;
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keksi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lean cookie consent for Google and Meta trackers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.12"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./playwright": {
|
|
20
|
+
"types": "./dist/playwright.d.ts",
|
|
21
|
+
"default": "./dist/playwright.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@playwright/test": ">=1.40"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"@playwright/test": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@playwright/test": "^1.64.1",
|
|
34
|
+
"@types/node": "^22.20.5",
|
|
35
|
+
"esbuild": "^0.28.2",
|
|
36
|
+
"happy-dom": "^21.0.4",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"vitest": "^5.0.5"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node scripts/build.mjs",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test:e2e": "pnpm -r --filter './examples/*' test:e2e"
|
|
45
|
+
}
|
|
6
46
|
}
|