keksi 0.1.0 → 0.1.2
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 +74 -67
- package/astro/CookieTable.astro +15 -0
- package/astro/Keksi.astro +16 -0
- package/astro/index.d.ts +2 -0
- package/astro/index.js +2 -0
- package/dist/cli.js +237 -0
- package/dist/i18n.d.ts +1 -0
- package/dist/index.js +15 -6
- package/dist/playwright.js +3 -0
- package/dist/render.d.ts +1 -1
- package/dist/scan.d.ts +18 -0
- package/dist/scan.js +190 -0
- package/dist/sveltekit.d.ts +18 -0
- package/dist/sveltekit.js +339 -0
- package/dist/types.d.ts +11 -0
- package/package.json +18 -2
package/README.md
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
# keksi
|
|
2
2
|
|
|
3
|
-
Lean cookie consent for
|
|
4
|
-
|
|
3
|
+
Lean cookie consent for Astro, SvelteKit and other server-rendered sites. Built around Finnish and EU law, with
|
|
4
|
+
texts in English, Finnish and Swedish.
|
|
5
5
|
|
|
6
|
-
keksi loads the trackers itself, and only after consent. You don't add their script tags
|
|
7
|
-
|
|
6
|
+
keksi loads the trackers itself, and only after consent. You don't add their script tags: you put their ids in the
|
|
7
|
+
config, so there's nothing to block.
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
9
|
+
- Google Analytics 4, Google Ads, Meta Pixel and YouTube. The short list is deliberate.
|
|
10
|
+
- About 5 KB gzipped, inline. No extra requests, no dependencies.
|
|
11
|
+
- Rendered on the server. Works on static pages, with no layout shift.
|
|
12
|
+
- The cookie policy table is generated from the same data.
|
|
12
13
|
|
|
13
14
|
## Setup
|
|
14
15
|
|
|
16
|
+
```sh
|
|
17
|
+
pnpm add keksi
|
|
18
|
+
```
|
|
19
|
+
|
|
15
20
|
```ts
|
|
16
21
|
// keksi.config.ts
|
|
17
22
|
import { defineConfig } from 'keksi';
|
|
@@ -25,66 +30,78 @@ export default defineConfig({
|
|
|
25
30
|
});
|
|
26
31
|
```
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
**Astro:** add the component right after `<body>` in your layout.
|
|
29
34
|
|
|
30
35
|
```astro
|
|
31
36
|
---
|
|
32
|
-
|
|
33
|
-
import { renderKeksi } from 'keksi';
|
|
37
|
+
import { Keksi } from 'keksi/astro';
|
|
34
38
|
import config from '../../keksi.config';
|
|
35
39
|
---
|
|
36
40
|
<body>
|
|
37
|
-
<
|
|
41
|
+
<Keksi config={config} />
|
|
38
42
|
...
|
|
39
|
-
<button type="button" data-keksi-open>Evästeasetukset</button>
|
|
40
43
|
</body>
|
|
41
44
|
```
|
|
42
45
|
|
|
46
|
+
**SvelteKit:** add the hook. It renders keksi outside the Svelte root and keeps it out of the client bundle.
|
|
47
|
+
|
|
43
48
|
```ts
|
|
44
|
-
//
|
|
45
|
-
import
|
|
46
|
-
import { renderKeksi } from 'keksi';
|
|
49
|
+
// src/hooks.server.ts
|
|
50
|
+
import { keksiHandle } from 'keksi/sveltekit';
|
|
47
51
|
import config from '../keksi.config';
|
|
48
52
|
|
|
49
|
-
const
|
|
53
|
+
export const handle = keksiHandle(config);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Any element with `data-keksi-open` reopens the settings. Put one in the footer:
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
```html
|
|
59
|
+
<button type="button" data-keksi-open>Evästeasetukset</button>
|
|
53
60
|
```
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
**Necessary cookies** from your framework or own code, such as a session, are listed under "Necessary". Find them
|
|
63
|
+
with the scanner, which visits the site without consent:
|
|
56
64
|
|
|
57
|
-
```
|
|
58
|
-
|
|
65
|
+
```sh
|
|
66
|
+
npx keksi scan https://staging.example.fi
|
|
59
67
|
```
|
|
60
68
|
|
|
61
|
-
|
|
69
|
+
It writes `keksi.cookies.json`, with purposes for common cookies filled in. Write the rest, then import the file:
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
```ts
|
|
72
|
+
import necessary from './keksi.cookies.json' with { type: 'json' };
|
|
73
|
+
|
|
74
|
+
export default defineConfig({ ga: 'G-XXXXXXX', necessary });
|
|
75
|
+
```
|
|
64
76
|
|
|
65
|
-
|
|
77
|
+
Rerunning keeps your purposes. `keksi scan <url> --check` fails when there are new entries, for CI. The scanner only
|
|
78
|
+
lists; it never blocks anything. Cookies set after login aren't visited, so add those by hand.
|
|
79
|
+
|
|
80
|
+
**Cookie policy page:** `<CookieTable config={config} />` from `keksi/astro`, or `cookieTable(config)` for an HTML
|
|
81
|
+
string.
|
|
82
|
+
|
|
83
|
+
## Tracking events
|
|
84
|
+
|
|
85
|
+
`gtag()` and `fbq()` are always defined, so you can call them anytime:
|
|
66
86
|
|
|
67
87
|
```ts
|
|
68
88
|
gtag('event', 'generate_lead');
|
|
69
89
|
fbq('track', 'Lead');
|
|
70
90
|
```
|
|
71
91
|
|
|
72
|
-
Calls made before consent
|
|
73
|
-
|
|
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.
|
|
92
|
+
- Calls made before consent are queued. They're sent only if the visitor consents on that page.
|
|
93
|
+
- Google gets Consent Mode v2 signals (basic mode).
|
|
94
|
+
- GA4 and Meta Pixel track single-page navigation themselves.
|
|
77
95
|
|
|
78
96
|
## YouTube
|
|
79
97
|
|
|
80
98
|
```html
|
|
81
|
-
<keksi-youtube video="aqz-KE-bpKQ" label="Esittelyvideo"></keksi-youtube>
|
|
99
|
+
<keksi-youtube video="aqz-KE-bpKQ" label="Esittelyvideo" poster="/img/poster.jpg"></keksi-youtube>
|
|
82
100
|
```
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
consent.
|
|
102
|
+
- Shows a placeholder until the visitor clicks it, then loads that one video from `youtube-nocookie.com`.
|
|
103
|
+
- With marketing consent, videos load directly.
|
|
104
|
+
- `poster` is optional and should be self-hosted. Thumbnails from YouTube would contact Google before consent.
|
|
88
105
|
|
|
89
106
|
## API
|
|
90
107
|
|
|
@@ -97,17 +114,8 @@ window.keksi.rejectAll();
|
|
|
97
114
|
document.addEventListener('keksi:change', (e) => e.detail);
|
|
98
115
|
```
|
|
99
116
|
|
|
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
117
|
## Testing
|
|
108
118
|
|
|
109
|
-
The examples use the same helpers you'd use in a client project's Playwright tests:
|
|
110
|
-
|
|
111
119
|
```ts
|
|
112
120
|
import { consentCookie, watchTracking } from 'keksi/playwright';
|
|
113
121
|
|
|
@@ -115,46 +123,45 @@ test('nothing tracks before consent', async ({ page }) => {
|
|
|
115
123
|
const tracking = watchTracking(page, config, { allow: ['session'] });
|
|
116
124
|
await page.goto('/');
|
|
117
125
|
await page.waitForLoadState('networkidle');
|
|
118
|
-
await tracking.check(); // throws on requests
|
|
126
|
+
await tracking.check(); // throws on tracker requests or stored data
|
|
119
127
|
});
|
|
120
128
|
|
|
121
129
|
// Skip the banner in other tests
|
|
122
130
|
await context.addCookies([consentCookie(config, baseURL, { analytics: true })]);
|
|
123
131
|
```
|
|
124
132
|
|
|
125
|
-
##
|
|
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
|
|
133
|
+
## Customizing
|
|
132
134
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
- **Styles** have zero specificity, so your CSS wins. Variables: `--keksi-bg`, `--keksi-fg`, `--keksi-line`,
|
|
136
|
+
`--keksi-radius`, `--keksi-focus`.
|
|
137
|
+
- **Language:** follows the page's language, otherwise Finnish. Pass `lang` to set it.
|
|
138
|
+
- **Texts:** `texts: { fi: { intro: '…' } }`.
|
|
139
|
+
- **CSP:** pass `nonce`.
|
|
140
|
+
- **Other frameworks:** `renderKeksi(config, { lang })` returns the HTML to put after `<body>`.
|
|
141
|
+
- **Consent cookie:** `cookie: { name, domain, days }`. Kept for 365 days by default.
|
|
136
142
|
|
|
137
|
-
##
|
|
143
|
+
## Legal basis
|
|
138
144
|
|
|
139
145
|
| Requirement | Basis | keksi |
|
|
140
146
|
|---|---|---|
|
|
141
|
-
| No non-essential storage
|
|
142
|
-
| Purpose, duration and third parties disclosed | §205, CJEU *Planet49* | Providers on the
|
|
143
|
-
| Separate choices
|
|
144
|
-
| Withdrawal
|
|
145
|
-
|
|
|
147
|
+
| No non-essential storage before consent | Act 917/2014 §205 | Trackers load only after consent |
|
|
148
|
+
| Purpose, duration and third parties disclosed | §205, CJEU *Planet49* | Providers on the banner, cookies and durations in settings and on the policy page |
|
|
149
|
+
| Separate choices, rejecting as easy as accepting | GDPR Art. 4(11), 7; Traficom 2024 | A switch per category, "Reject all" on the banner |
|
|
150
|
+
| Withdrawal is easy and has real effect | GDPR Art. 7(3) | Deletes the category's cookies and localStorage on your domain, then reloads |
|
|
151
|
+
| Consent can be demonstrated | GDPR Art. 7(1) | Consent cookie with id, time and version. Adding or removing a provider asks again |
|
|
146
152
|
| Transfers outside the EU disclosed | GDPR Art. 13(1)(f) | DPF transfer stated per provider |
|
|
147
153
|
|
|
148
|
-
|
|
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`.
|
|
154
|
+
Cookies that Google and Meta set on their own domains can't be deleted from your site. The policy page lists them as
|
|
155
|
+
third-party cookies.
|
|
152
156
|
|
|
153
157
|
## Development
|
|
154
158
|
|
|
155
159
|
```sh
|
|
156
160
|
pnpm install
|
|
157
|
-
pnpm build
|
|
158
|
-
pnpm test
|
|
159
|
-
pnpm test:e2e
|
|
161
|
+
pnpm build # prints the runtime size
|
|
162
|
+
pnpm test
|
|
163
|
+
pnpm test:e2e # Astro and SvelteKit examples
|
|
160
164
|
```
|
|
165
|
+
|
|
166
|
+
To add a provider: add its data to `src/providers.ts`, a config key with the same id to `KeksiConfig`, and its
|
|
167
|
+
loading to `load()` in `src/runtime.ts`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { cookieTable, type KeksiConfig, type Locale } from 'keksi';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
config: KeksiConfig;
|
|
6
|
+
lang?: Locale;
|
|
7
|
+
headingLevel?: 2 | 3 | 4;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { config, headingLevel } = Astro.props;
|
|
11
|
+
const locale = Astro.currentLocale?.slice(0, 2);
|
|
12
|
+
const lang = Astro.props.lang ?? (['fi', 'sv', 'en'].includes(locale!) ? (locale as Locale) : 'fi');
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<Fragment set:html={cookieTable(config, { lang, headingLevel })} />
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { renderKeksi, type KeksiConfig, type Locale } from 'keksi';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
config: KeksiConfig;
|
|
6
|
+
/** Defaults to Astro's current locale, then `fi`. */
|
|
7
|
+
lang?: Locale;
|
|
8
|
+
nonce?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { config, nonce } = Astro.props;
|
|
12
|
+
const locale = Astro.currentLocale?.slice(0, 2);
|
|
13
|
+
const lang = Astro.props.lang ?? (['fi', 'sv', 'en'].includes(locale!) ? (locale as Locale) : 'fi');
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<Fragment set:html={renderKeksi(config, { lang, nonce })} />
|
package/astro/index.d.ts
ADDED
package/astro/index.js
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
// src/providers.ts
|
|
8
|
+
var GOOGLE = { company: "Google Ireland Limited", privacyPolicy: "https://policies.google.com/privacy", transfer: "dpf" };
|
|
9
|
+
var META = { company: "Meta Platforms Ireland Limited", privacyPolicy: "https://www.facebook.com/privacy/policy/", transfer: "dpf" };
|
|
10
|
+
var providers = [
|
|
11
|
+
{
|
|
12
|
+
id: "ga",
|
|
13
|
+
name: "Google Analytics",
|
|
14
|
+
...GOOGLE,
|
|
15
|
+
category: "analytics",
|
|
16
|
+
purpose: {
|
|
17
|
+
fi: "K\xE4vij\xE4tilastot: k\xE4vij\xE4m\xE4\xE4r\xE4t, liikenteen l\xE4hteet ja sivujen k\xE4ytt\xF6.",
|
|
18
|
+
sv: "Bes\xF6ksstatistik: antal bes\xF6kare, trafikk\xE4llor och hur sidorna anv\xE4nds.",
|
|
19
|
+
en: "Visitor statistics: visitor numbers, traffic sources and page usage."
|
|
20
|
+
},
|
|
21
|
+
cookies: [
|
|
22
|
+
{ name: "_ga", duration: "2y" },
|
|
23
|
+
{ name: "_ga_*", duration: "2y" }
|
|
24
|
+
],
|
|
25
|
+
hosts: ["google-analytics.com", "analytics.google.com", "googletagmanager.com"]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "googleAds",
|
|
29
|
+
name: "Google Ads",
|
|
30
|
+
...GOOGLE,
|
|
31
|
+
category: "marketing",
|
|
32
|
+
purpose: {
|
|
33
|
+
fi: "Mainonnan tehon mittaaminen (konversiot) ja mainosten kohdentaminen sivuston k\xE4vij\xF6ille.",
|
|
34
|
+
sv: "M\xE4tning av annonsernas effekt (konverteringar) och riktad annonsering till bes\xF6kare.",
|
|
35
|
+
en: "Measuring ad performance (conversions) and showing ads to site visitors."
|
|
36
|
+
},
|
|
37
|
+
cookies: [
|
|
38
|
+
{ name: "_gcl_au", duration: "90d" },
|
|
39
|
+
{ name: "_gcl_aw", duration: "90d" },
|
|
40
|
+
{ name: "_gcl_dc", duration: "90d" },
|
|
41
|
+
{ name: "_gcl_gs", duration: "90d" },
|
|
42
|
+
{ name: "_gac_*", duration: "90d" },
|
|
43
|
+
{ name: "_gcl_ls", duration: "persistent", storage: "localStorage" },
|
|
44
|
+
{ name: "IDE", duration: "13mo", host: "doubleclick.net" },
|
|
45
|
+
{ name: "DSID", duration: "14d", host: "doubleclick.net" },
|
|
46
|
+
{ name: "test_cookie", duration: "15min", host: "doubleclick.net" }
|
|
47
|
+
],
|
|
48
|
+
hosts: ["googletagmanager.com", "googleadservices.com", "doubleclick.net", "googlesyndication.com"]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "meta",
|
|
52
|
+
name: "Meta Pixel",
|
|
53
|
+
...META,
|
|
54
|
+
category: "marketing",
|
|
55
|
+
purpose: {
|
|
56
|
+
fi: "Mainonnan tehon mittaaminen ja mainosten kohdentaminen Facebookissa ja Instagramissa.",
|
|
57
|
+
sv: "M\xE4tning av annonsernas effekt och riktad annonsering p\xE5 Facebook och Instagram.",
|
|
58
|
+
en: "Measuring ad performance and targeting ads on Facebook and Instagram."
|
|
59
|
+
},
|
|
60
|
+
cookies: [
|
|
61
|
+
{ name: "_fbp", duration: "90d" },
|
|
62
|
+
{ name: "_fbc", duration: "90d" },
|
|
63
|
+
{ name: "fr", duration: "90d", host: "facebook.com" }
|
|
64
|
+
],
|
|
65
|
+
hosts: ["facebook.net", "facebook.com"]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "youtube",
|
|
69
|
+
name: "YouTube",
|
|
70
|
+
...GOOGLE,
|
|
71
|
+
category: "marketing",
|
|
72
|
+
purpose: {
|
|
73
|
+
fi: "Upotettujen videoiden toistaminen. YouTube voi k\xE4ytt\xE4\xE4 tietoja mainontaan.",
|
|
74
|
+
sv: "Uppspelning av inb\xE4ddade videor. YouTube kan anv\xE4nda uppgifterna f\xF6r annonsering.",
|
|
75
|
+
en: "Playing embedded videos. YouTube may use the data for advertising."
|
|
76
|
+
},
|
|
77
|
+
cookies: [
|
|
78
|
+
{ name: "YSC", duration: "session", host: "youtube.com" },
|
|
79
|
+
{ name: "VISITOR_INFO1_LIVE", duration: "6mo", host: "youtube.com" },
|
|
80
|
+
{ name: "VISITOR_PRIVACY_METADATA", duration: "6mo", host: "youtube.com" },
|
|
81
|
+
{ name: "__Secure-YNID", duration: "6mo", host: "youtube.com" },
|
|
82
|
+
{ name: "__Secure-ROLLOUT_TOKEN", duration: "6mo", host: "youtube.com" },
|
|
83
|
+
{ name: "PREF", duration: "8mo", host: "youtube.com" },
|
|
84
|
+
{ name: "yt-remote-*, ytidb::*, yt-player-*", duration: "persistent", host: "youtube-nocookie.com", storage: "localStorage" }
|
|
85
|
+
],
|
|
86
|
+
hosts: ["youtube.com", "youtube-nocookie.com", "ytimg.com", "googlevideo.com"]
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// src/scan.ts
|
|
91
|
+
var known = (purpose, fi, sv) => ({ en: purpose, fi, sv });
|
|
92
|
+
var SESSION = known("Keeps you signed in.", "Pit\xE4\xE4 sinut kirjautuneena.", "H\xE5ller dig inloggad.");
|
|
93
|
+
var CSRF = known("Protects forms against forgery.", "Suojaa lomakkeita v\xE4\xE4renn\xF6ksilt\xE4.", "Skyddar formul\xE4r mot f\xF6rfalskning.");
|
|
94
|
+
var SECURITY = known(
|
|
95
|
+
"Protects the site against bots and attacks.",
|
|
96
|
+
"Suojaa sivustoa boteilta ja hy\xF6kk\xE4yksilt\xE4.",
|
|
97
|
+
"Skyddar webbplatsen mot botar och attacker."
|
|
98
|
+
);
|
|
99
|
+
var purposes = {
|
|
100
|
+
NEXT_LOCALE: known("Remembers your language.", "Muistaa valitsemasi kielen.", "Kommer ih\xE5g ditt spr\xE5k."),
|
|
101
|
+
"next-auth.session-token": SESSION,
|
|
102
|
+
"__Secure-next-auth.session-token": SESSION,
|
|
103
|
+
"authjs.session-token": SESSION,
|
|
104
|
+
"__Secure-authjs.session-token": SESSION,
|
|
105
|
+
"next-auth.csrf-token": CSRF,
|
|
106
|
+
"__Host-next-auth.csrf-token": CSRF,
|
|
107
|
+
"authjs.csrf-token": CSRF,
|
|
108
|
+
"__Host-authjs.csrf-token": CSRF,
|
|
109
|
+
"next-auth.callback-url": known("Returns you to the right page after signing in.", "Palauttaa oikealle sivulle kirjautumisen j\xE4lkeen.", "\xC5terf\xF6r dig till r\xE4tt sida efter inloggning."),
|
|
110
|
+
__cf_bm: SECURITY,
|
|
111
|
+
cf_clearance: SECURITY,
|
|
112
|
+
_cfuvid: SECURITY,
|
|
113
|
+
PHPSESSID: SESSION,
|
|
114
|
+
"connect.sid": SESSION,
|
|
115
|
+
session: SESSION,
|
|
116
|
+
csrftoken: CSRF,
|
|
117
|
+
_csrf: CSRF,
|
|
118
|
+
"sveltekit:*": known("Restores scroll position and page state when you navigate back.", "Palauttaa vierityskohdan ja sivun tilan, kun palaat takaisin.", "\xC5terst\xE4ller scrollposition och sidans l\xE4ge n\xE4r du g\xE5r tillbaka.")
|
|
119
|
+
};
|
|
120
|
+
var trackerNames = providers.flatMap((p) => p.cookies.flatMap((c) => c.name.split(", ")));
|
|
121
|
+
var trackerHosts = providers.flatMap((p) => p.hosts);
|
|
122
|
+
var matches = (patterns, name) => patterns.some((p) => p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : name === p);
|
|
123
|
+
function purposeOf(name) {
|
|
124
|
+
const k = Object.keys(purposes).find((p) => matches([p], name));
|
|
125
|
+
return k ? purposes[k] : void 0;
|
|
126
|
+
}
|
|
127
|
+
function durationOf(expires, now = Date.now() / 1e3) {
|
|
128
|
+
if (expires < 0) return "session";
|
|
129
|
+
const minutes = (expires - now) / 60;
|
|
130
|
+
const days = minutes / 1440;
|
|
131
|
+
if (days >= 365) return `${Math.round(days / 365)}y`;
|
|
132
|
+
if (days >= 30) return `${Math.round(days / 30.4)}mo`;
|
|
133
|
+
if (days >= 1) return `${Math.round(days)}d`;
|
|
134
|
+
return `${Math.max(1, Math.round(minutes))}min`;
|
|
135
|
+
}
|
|
136
|
+
async function scan(url2, { pages = 20, cookieName = "keksi" } = {}) {
|
|
137
|
+
const { chromium } = await import("@playwright/test");
|
|
138
|
+
const browser = await chromium.launch();
|
|
139
|
+
const origin = new URL(url2).origin;
|
|
140
|
+
const host = new URL(url2).hostname;
|
|
141
|
+
const found2 = /* @__PURE__ */ new Map();
|
|
142
|
+
try {
|
|
143
|
+
const context = await browser.newContext();
|
|
144
|
+
const page = await context.newPage();
|
|
145
|
+
const queue = [new URL(url2).href];
|
|
146
|
+
const seen = new Set(queue);
|
|
147
|
+
for (let i = 0; i < queue.length && i < pages; i++) {
|
|
148
|
+
await page.goto(queue[i], { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
|
|
149
|
+
});
|
|
150
|
+
const { links, local, session } = await page.evaluate(() => ({
|
|
151
|
+
links: [...document.querySelectorAll("a[href]")].map((a) => a.href),
|
|
152
|
+
local: Object.keys(localStorage),
|
|
153
|
+
session: Object.keys(sessionStorage)
|
|
154
|
+
}));
|
|
155
|
+
for (const key2 of local) found2.set(`l:${key2}`, { name: key2, duration: "persistent", storage: "localStorage" });
|
|
156
|
+
for (const key2 of session) found2.set(`s:${key2}`, { name: key2, duration: "session", storage: "sessionStorage" });
|
|
157
|
+
for (const link of links) {
|
|
158
|
+
const u = new URL(link);
|
|
159
|
+
u.hash = "";
|
|
160
|
+
if (u.origin === origin && !seen.has(u.href) && !/\.(pdf|zip|jpe?g|png|webp|svg|mp4)$/i.test(u.pathname)) {
|
|
161
|
+
seen.add(u.href);
|
|
162
|
+
queue.push(u.href);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const c of await context.cookies()) {
|
|
167
|
+
const domain = c.domain.replace(/^\./, "");
|
|
168
|
+
const own = host === domain || host.endsWith(`.${domain}`);
|
|
169
|
+
found2.set(`c:${c.name}`, { name: c.name, duration: durationOf(c.expires), ...own ? {} : { host: domain } });
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await browser.close();
|
|
173
|
+
}
|
|
174
|
+
return [...found2.values()].filter((c) => c.name !== cookieName && !matches(trackerNames, c.name)).filter((c) => !c.host || !trackerHosts.some((t) => c.host === t || c.host.endsWith(`.${t}`))).map((c) => ({ ...c, purpose: purposeOf(c.name) ?? { en: "", fi: "", sv: "" } }));
|
|
175
|
+
}
|
|
176
|
+
var key = (c) => `${c.storage ?? "cookie"}:${c.name}`;
|
|
177
|
+
function merge(existing2, found2) {
|
|
178
|
+
const old = new Map(existing2.map((c) => [key(c), c]));
|
|
179
|
+
const now = new Set(found2.map(key));
|
|
180
|
+
const added2 = found2.filter((c) => !old.has(key(c)));
|
|
181
|
+
const missing2 = existing2.filter((c) => !now.has(key(c)));
|
|
182
|
+
const list2 = [
|
|
183
|
+
...existing2.map((c) => {
|
|
184
|
+
const f = found2.find((x) => key(x) === key(c));
|
|
185
|
+
return f ? { ...c, duration: f.duration } : c;
|
|
186
|
+
}),
|
|
187
|
+
...added2
|
|
188
|
+
];
|
|
189
|
+
return { list: list2, added: added2, missing: missing2 };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/cli.ts
|
|
193
|
+
var HELP = `Usage: keksi scan <url> [options]
|
|
194
|
+
|
|
195
|
+
Lists the cookies and storage your site sets without consent, for the "Necessary" section.
|
|
196
|
+
|
|
197
|
+
Options:
|
|
198
|
+
--out <file> Output file (default: keksi.cookies.json)
|
|
199
|
+
--pages <n> Most pages to visit (default: 20)
|
|
200
|
+
--cookie <name> Consent cookie name, if changed (default: keksi)
|
|
201
|
+
--check Write nothing; exit 1 if the file is missing entries
|
|
202
|
+
`;
|
|
203
|
+
var { values, positionals } = parseArgs({
|
|
204
|
+
allowPositionals: true,
|
|
205
|
+
options: {
|
|
206
|
+
out: { type: "string", default: "keksi.cookies.json" },
|
|
207
|
+
pages: { type: "string", default: "20" },
|
|
208
|
+
cookie: { type: "string", default: "keksi" },
|
|
209
|
+
check: { type: "boolean", default: false },
|
|
210
|
+
help: { type: "boolean", short: "h", default: false }
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
var [command, url] = positionals;
|
|
214
|
+
if (values.help || command !== "scan" || !url) {
|
|
215
|
+
process.stdout.write(HELP);
|
|
216
|
+
process.exit(values.help ? 0 : 1);
|
|
217
|
+
}
|
|
218
|
+
var existing = existsSync(values.out) ? JSON.parse(readFileSync(values.out, "utf8")) : [];
|
|
219
|
+
var found = await scan(url, { pages: Number(values.pages), cookieName: values.cookie });
|
|
220
|
+
var { list, added, missing } = merge(existing, found);
|
|
221
|
+
var label = (c) => `${c.name}${c.storage ? ` (${c.storage})` : ""}`;
|
|
222
|
+
var blank = (c) => !c.purpose || typeof c.purpose === "object" && !Object.values(c.purpose).some(Boolean);
|
|
223
|
+
for (const c of added) console.log(`+ ${label(c)}`);
|
|
224
|
+
for (const c of missing) console.log(`? ${label(c)}: not seen, remove it unless it's set after login or on unscanned pages`);
|
|
225
|
+
if (values.check) {
|
|
226
|
+
if (added.length) {
|
|
227
|
+
console.error(`${values.out} is missing ${added.length} entr${added.length === 1 ? "y" : "ies"}. Run keksi scan ${url}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
console.log(`${values.out} is up to date.`);
|
|
231
|
+
} else {
|
|
232
|
+
writeFileSync(values.out, `${JSON.stringify(list, null, 2)}
|
|
233
|
+
`);
|
|
234
|
+
const todo = list.filter(blank);
|
|
235
|
+
console.log(`Wrote ${list.length} entr${list.length === 1 ? "y" : "ies"} to ${values.out}.`);
|
|
236
|
+
if (todo.length) console.log(`Add a purpose for: ${todo.map(label).join(", ")}`);
|
|
237
|
+
}
|
package/dist/i18n.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ var fi = {
|
|
|
24
24
|
dpf: "Tietoja voidaan siirt\xE4\xE4 Yhdysvaltoihin EU:n ja Yhdysvaltojen v\xE4lisen tietosuojakehyksen (DPF) perusteella.",
|
|
25
25
|
name: "Nimi",
|
|
26
26
|
duration: "Kesto",
|
|
27
|
+
purpose: "Tarkoitus",
|
|
27
28
|
setBy: "Asettaja",
|
|
28
29
|
session: "istunto",
|
|
29
30
|
persistent: "kunnes poistetaan",
|
|
@@ -60,6 +61,7 @@ var sv = {
|
|
|
60
61
|
dpf: "Uppgifter kan \xF6verf\xF6ras till USA med st\xF6d av ramverket f\xF6r dataskydd mellan EU och USA (DPF).",
|
|
61
62
|
name: "Namn",
|
|
62
63
|
duration: "Varaktighet",
|
|
64
|
+
purpose: "Syfte",
|
|
63
65
|
setBy: "S\xE4tts av",
|
|
64
66
|
session: "session",
|
|
65
67
|
persistent: "tills den raderas",
|
|
@@ -96,6 +98,7 @@ var en = {
|
|
|
96
98
|
dpf: "Data may be transferred to the United States under the EU\u2013US Data Privacy Framework (DPF).",
|
|
97
99
|
name: "Name",
|
|
98
100
|
duration: "Duration",
|
|
101
|
+
purpose: "Purpose",
|
|
99
102
|
setBy: "Set by",
|
|
100
103
|
session: "session",
|
|
101
104
|
persistent: "until deleted",
|
|
@@ -288,30 +291,36 @@ function sections(config, lang, h, dialog) {
|
|
|
288
291
|
const t = textsFor(config, lang);
|
|
289
292
|
const active = activeProviders(config);
|
|
290
293
|
const { name, days } = runtimeData(config, lang);
|
|
294
|
+
const local = (v) => typeof v === "object" ? v[lang] ?? Object.values(v).find(Boolean) : v;
|
|
291
295
|
const site = {
|
|
292
296
|
name: t.thisSite,
|
|
293
|
-
|
|
294
|
-
|
|
297
|
+
cookies: [
|
|
298
|
+
{ name, duration: days % 365 ? `${days}d` : `${days / 365}y`, purpose: t.consentPurpose },
|
|
299
|
+
...(config.necessary ?? []).map((c) => ({ ...c, purpose: local(c.purpose) }))
|
|
300
|
+
]
|
|
295
301
|
};
|
|
296
302
|
return ["necessary", ...categoriesOf(config)].map((c) => {
|
|
297
303
|
const items = c === "necessary" ? [site] : active.filter((p) => p.category === c).map((p) => ({ ...p, purpose: p.purpose[lang] }));
|
|
298
304
|
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
305
|
const list2 = items.map(
|
|
300
|
-
(p) => `<div class="keksi-provider"><h${h + 1}>${e(p.name)}</h${h + 1}
|
|
306
|
+
(p) => `<div class="keksi-provider"><h${h + 1}>${e(p.name)}</h${h + 1}>${p.purpose ? `<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
307
|
).join("");
|
|
302
308
|
const body = dialog ? `<details><summary>${e(t.providers)} (${items.length})</summary>${list2}</details>` : list2;
|
|
303
309
|
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
310
|
}).join("");
|
|
305
311
|
}
|
|
306
312
|
function table(cookies, t) {
|
|
313
|
+
const purpose = cookies.some((c) => c.purpose);
|
|
307
314
|
const rows = cookies.map(
|
|
308
|
-
(c) => `<tr><td><code>${e(c.name)}</code>${c.storage ? ` (${c.storage})` : ""}</td
|
|
315
|
+
(c) => `<tr><td><code>${e(c.name)}</code>${c.storage ? ` (${c.storage})` : ""}</td>` + (purpose ? `<td>${e(c.purpose ?? "")}</td>` : "") + `<td>${e(duration(c.duration, t))}</td><td>${e(c.host ?? t.thisSite)}</td></tr>`
|
|
309
316
|
).join("");
|
|
310
|
-
return `<table><thead><tr><th scope="col">${e(t.name)}</th
|
|
317
|
+
return `<table><thead><tr><th scope="col">${e(t.name)}</th>` + (purpose ? `<th scope="col">${e(t.purpose)}</th>` : "") + `<th scope="col">${e(t.duration)}</th><th scope="col">${e(t.setBy)}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
311
318
|
}
|
|
312
319
|
function duration(d, t) {
|
|
313
320
|
if (d === "session" || d === "persistent") return t[d];
|
|
314
|
-
const
|
|
321
|
+
const m = d.match(/^(\d+)(y|mo|d|min)$/);
|
|
322
|
+
if (!m) return d;
|
|
323
|
+
const [, n, unit] = m;
|
|
315
324
|
const [one, many] = t[unit].split("|");
|
|
316
325
|
return `${n} ${n === "1" ? one : many}`;
|
|
317
326
|
}
|
package/dist/playwright.js
CHANGED
|
@@ -24,6 +24,7 @@ var fi = {
|
|
|
24
24
|
dpf: "Tietoja voidaan siirt\xE4\xE4 Yhdysvaltoihin EU:n ja Yhdysvaltojen v\xE4lisen tietosuojakehyksen (DPF) perusteella.",
|
|
25
25
|
name: "Nimi",
|
|
26
26
|
duration: "Kesto",
|
|
27
|
+
purpose: "Tarkoitus",
|
|
27
28
|
setBy: "Asettaja",
|
|
28
29
|
session: "istunto",
|
|
29
30
|
persistent: "kunnes poistetaan",
|
|
@@ -60,6 +61,7 @@ var sv = {
|
|
|
60
61
|
dpf: "Uppgifter kan \xF6verf\xF6ras till USA med st\xF6d av ramverket f\xF6r dataskydd mellan EU och USA (DPF).",
|
|
61
62
|
name: "Namn",
|
|
62
63
|
duration: "Varaktighet",
|
|
64
|
+
purpose: "Syfte",
|
|
63
65
|
setBy: "S\xE4tts av",
|
|
64
66
|
session: "session",
|
|
65
67
|
persistent: "tills den raderas",
|
|
@@ -96,6 +98,7 @@ var en = {
|
|
|
96
98
|
dpf: "Data may be transferred to the United States under the EU\u2013US Data Privacy Framework (DPF).",
|
|
97
99
|
name: "Name",
|
|
98
100
|
duration: "Duration",
|
|
101
|
+
purpose: "Purpose",
|
|
99
102
|
setBy: "Set by",
|
|
100
103
|
session: "session",
|
|
101
104
|
persistent: "until deleted",
|
package/dist/render.d.ts
CHANGED
|
@@ -13,4 +13,4 @@ export interface CookieTableOptions {
|
|
|
13
13
|
}
|
|
14
14
|
/** Cookie policy section generated from the same data as the banner. */
|
|
15
15
|
export declare function cookieTable(config: KeksiConfig, { lang, headingLevel }?: CookieTableOptions): string;
|
|
16
|
-
export declare function duration(d: Duration, t: Texts): string;
|
|
16
|
+
export declare function duration(d: Duration | string, t: Texts): string;
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { NecessaryCookie } from './types.js';
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
/** Most pages to visit, following same-origin links. */
|
|
4
|
+
pages?: number;
|
|
5
|
+
/** Consent cookie name, if changed in the config. */
|
|
6
|
+
cookieName?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function purposeOf(name: string): NecessaryCookie['purpose'] | undefined;
|
|
9
|
+
/** Rounds a cookie lifetime to the units the policy page shows. */
|
|
10
|
+
export declare function durationOf(expires: number, now?: number): string;
|
|
11
|
+
/** Visits the site without consent and returns what it stores, minus keksi's own and known trackers. */
|
|
12
|
+
export declare function scan(url: string, { pages, cookieName }?: ScanOptions): Promise<NecessaryCookie[]>;
|
|
13
|
+
/** Keeps hand-written purposes, updates durations, adds new entries and reports ones no longer seen. */
|
|
14
|
+
export declare function merge(existing: NecessaryCookie[], found: NecessaryCookie[]): {
|
|
15
|
+
list: NecessaryCookie[];
|
|
16
|
+
added: NecessaryCookie[];
|
|
17
|
+
missing: NecessaryCookie[];
|
|
18
|
+
};
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// src/providers.ts
|
|
2
|
+
var GOOGLE = { company: "Google Ireland Limited", privacyPolicy: "https://policies.google.com/privacy", transfer: "dpf" };
|
|
3
|
+
var META = { company: "Meta Platforms Ireland Limited", privacyPolicy: "https://www.facebook.com/privacy/policy/", transfer: "dpf" };
|
|
4
|
+
var providers = [
|
|
5
|
+
{
|
|
6
|
+
id: "ga",
|
|
7
|
+
name: "Google Analytics",
|
|
8
|
+
...GOOGLE,
|
|
9
|
+
category: "analytics",
|
|
10
|
+
purpose: {
|
|
11
|
+
fi: "K\xE4vij\xE4tilastot: k\xE4vij\xE4m\xE4\xE4r\xE4t, liikenteen l\xE4hteet ja sivujen k\xE4ytt\xF6.",
|
|
12
|
+
sv: "Bes\xF6ksstatistik: antal bes\xF6kare, trafikk\xE4llor och hur sidorna anv\xE4nds.",
|
|
13
|
+
en: "Visitor statistics: visitor numbers, traffic sources and page usage."
|
|
14
|
+
},
|
|
15
|
+
cookies: [
|
|
16
|
+
{ name: "_ga", duration: "2y" },
|
|
17
|
+
{ name: "_ga_*", duration: "2y" }
|
|
18
|
+
],
|
|
19
|
+
hosts: ["google-analytics.com", "analytics.google.com", "googletagmanager.com"]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "googleAds",
|
|
23
|
+
name: "Google Ads",
|
|
24
|
+
...GOOGLE,
|
|
25
|
+
category: "marketing",
|
|
26
|
+
purpose: {
|
|
27
|
+
fi: "Mainonnan tehon mittaaminen (konversiot) ja mainosten kohdentaminen sivuston k\xE4vij\xF6ille.",
|
|
28
|
+
sv: "M\xE4tning av annonsernas effekt (konverteringar) och riktad annonsering till bes\xF6kare.",
|
|
29
|
+
en: "Measuring ad performance (conversions) and showing ads to site visitors."
|
|
30
|
+
},
|
|
31
|
+
cookies: [
|
|
32
|
+
{ name: "_gcl_au", duration: "90d" },
|
|
33
|
+
{ name: "_gcl_aw", duration: "90d" },
|
|
34
|
+
{ name: "_gcl_dc", duration: "90d" },
|
|
35
|
+
{ name: "_gcl_gs", duration: "90d" },
|
|
36
|
+
{ name: "_gac_*", duration: "90d" },
|
|
37
|
+
{ name: "_gcl_ls", duration: "persistent", storage: "localStorage" },
|
|
38
|
+
{ name: "IDE", duration: "13mo", host: "doubleclick.net" },
|
|
39
|
+
{ name: "DSID", duration: "14d", host: "doubleclick.net" },
|
|
40
|
+
{ name: "test_cookie", duration: "15min", host: "doubleclick.net" }
|
|
41
|
+
],
|
|
42
|
+
hosts: ["googletagmanager.com", "googleadservices.com", "doubleclick.net", "googlesyndication.com"]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "meta",
|
|
46
|
+
name: "Meta Pixel",
|
|
47
|
+
...META,
|
|
48
|
+
category: "marketing",
|
|
49
|
+
purpose: {
|
|
50
|
+
fi: "Mainonnan tehon mittaaminen ja mainosten kohdentaminen Facebookissa ja Instagramissa.",
|
|
51
|
+
sv: "M\xE4tning av annonsernas effekt och riktad annonsering p\xE5 Facebook och Instagram.",
|
|
52
|
+
en: "Measuring ad performance and targeting ads on Facebook and Instagram."
|
|
53
|
+
},
|
|
54
|
+
cookies: [
|
|
55
|
+
{ name: "_fbp", duration: "90d" },
|
|
56
|
+
{ name: "_fbc", duration: "90d" },
|
|
57
|
+
{ name: "fr", duration: "90d", host: "facebook.com" }
|
|
58
|
+
],
|
|
59
|
+
hosts: ["facebook.net", "facebook.com"]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "youtube",
|
|
63
|
+
name: "YouTube",
|
|
64
|
+
...GOOGLE,
|
|
65
|
+
category: "marketing",
|
|
66
|
+
purpose: {
|
|
67
|
+
fi: "Upotettujen videoiden toistaminen. YouTube voi k\xE4ytt\xE4\xE4 tietoja mainontaan.",
|
|
68
|
+
sv: "Uppspelning av inb\xE4ddade videor. YouTube kan anv\xE4nda uppgifterna f\xF6r annonsering.",
|
|
69
|
+
en: "Playing embedded videos. YouTube may use the data for advertising."
|
|
70
|
+
},
|
|
71
|
+
cookies: [
|
|
72
|
+
{ name: "YSC", duration: "session", host: "youtube.com" },
|
|
73
|
+
{ name: "VISITOR_INFO1_LIVE", duration: "6mo", host: "youtube.com" },
|
|
74
|
+
{ name: "VISITOR_PRIVACY_METADATA", duration: "6mo", host: "youtube.com" },
|
|
75
|
+
{ name: "__Secure-YNID", duration: "6mo", host: "youtube.com" },
|
|
76
|
+
{ name: "__Secure-ROLLOUT_TOKEN", duration: "6mo", host: "youtube.com" },
|
|
77
|
+
{ name: "PREF", duration: "8mo", host: "youtube.com" },
|
|
78
|
+
{ name: "yt-remote-*, ytidb::*, yt-player-*", duration: "persistent", host: "youtube-nocookie.com", storage: "localStorage" }
|
|
79
|
+
],
|
|
80
|
+
hosts: ["youtube.com", "youtube-nocookie.com", "ytimg.com", "googlevideo.com"]
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// src/scan.ts
|
|
85
|
+
var known = (purpose, fi, sv) => ({ en: purpose, fi, sv });
|
|
86
|
+
var SESSION = known("Keeps you signed in.", "Pit\xE4\xE4 sinut kirjautuneena.", "H\xE5ller dig inloggad.");
|
|
87
|
+
var CSRF = known("Protects forms against forgery.", "Suojaa lomakkeita v\xE4\xE4renn\xF6ksilt\xE4.", "Skyddar formul\xE4r mot f\xF6rfalskning.");
|
|
88
|
+
var SECURITY = known(
|
|
89
|
+
"Protects the site against bots and attacks.",
|
|
90
|
+
"Suojaa sivustoa boteilta ja hy\xF6kk\xE4yksilt\xE4.",
|
|
91
|
+
"Skyddar webbplatsen mot botar och attacker."
|
|
92
|
+
);
|
|
93
|
+
var purposes = {
|
|
94
|
+
NEXT_LOCALE: known("Remembers your language.", "Muistaa valitsemasi kielen.", "Kommer ih\xE5g ditt spr\xE5k."),
|
|
95
|
+
"next-auth.session-token": SESSION,
|
|
96
|
+
"__Secure-next-auth.session-token": SESSION,
|
|
97
|
+
"authjs.session-token": SESSION,
|
|
98
|
+
"__Secure-authjs.session-token": SESSION,
|
|
99
|
+
"next-auth.csrf-token": CSRF,
|
|
100
|
+
"__Host-next-auth.csrf-token": CSRF,
|
|
101
|
+
"authjs.csrf-token": CSRF,
|
|
102
|
+
"__Host-authjs.csrf-token": CSRF,
|
|
103
|
+
"next-auth.callback-url": known("Returns you to the right page after signing in.", "Palauttaa oikealle sivulle kirjautumisen j\xE4lkeen.", "\xC5terf\xF6r dig till r\xE4tt sida efter inloggning."),
|
|
104
|
+
__cf_bm: SECURITY,
|
|
105
|
+
cf_clearance: SECURITY,
|
|
106
|
+
_cfuvid: SECURITY,
|
|
107
|
+
PHPSESSID: SESSION,
|
|
108
|
+
"connect.sid": SESSION,
|
|
109
|
+
session: SESSION,
|
|
110
|
+
csrftoken: CSRF,
|
|
111
|
+
_csrf: CSRF,
|
|
112
|
+
"sveltekit:*": known("Restores scroll position and page state when you navigate back.", "Palauttaa vierityskohdan ja sivun tilan, kun palaat takaisin.", "\xC5terst\xE4ller scrollposition och sidans l\xE4ge n\xE4r du g\xE5r tillbaka.")
|
|
113
|
+
};
|
|
114
|
+
var trackerNames = providers.flatMap((p) => p.cookies.flatMap((c) => c.name.split(", ")));
|
|
115
|
+
var trackerHosts = providers.flatMap((p) => p.hosts);
|
|
116
|
+
var matches = (patterns, name) => patterns.some((p) => p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : name === p);
|
|
117
|
+
function purposeOf(name) {
|
|
118
|
+
const k = Object.keys(purposes).find((p) => matches([p], name));
|
|
119
|
+
return k ? purposes[k] : void 0;
|
|
120
|
+
}
|
|
121
|
+
function durationOf(expires, now = Date.now() / 1e3) {
|
|
122
|
+
if (expires < 0) return "session";
|
|
123
|
+
const minutes = (expires - now) / 60;
|
|
124
|
+
const days = minutes / 1440;
|
|
125
|
+
if (days >= 365) return `${Math.round(days / 365)}y`;
|
|
126
|
+
if (days >= 30) return `${Math.round(days / 30.4)}mo`;
|
|
127
|
+
if (days >= 1) return `${Math.round(days)}d`;
|
|
128
|
+
return `${Math.max(1, Math.round(minutes))}min`;
|
|
129
|
+
}
|
|
130
|
+
async function scan(url, { pages = 20, cookieName = "keksi" } = {}) {
|
|
131
|
+
const { chromium } = await import("@playwright/test");
|
|
132
|
+
const browser = await chromium.launch();
|
|
133
|
+
const origin = new URL(url).origin;
|
|
134
|
+
const host = new URL(url).hostname;
|
|
135
|
+
const found = /* @__PURE__ */ new Map();
|
|
136
|
+
try {
|
|
137
|
+
const context = await browser.newContext();
|
|
138
|
+
const page = await context.newPage();
|
|
139
|
+
const queue = [new URL(url).href];
|
|
140
|
+
const seen = new Set(queue);
|
|
141
|
+
for (let i = 0; i < queue.length && i < pages; i++) {
|
|
142
|
+
await page.goto(queue[i], { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
|
|
143
|
+
});
|
|
144
|
+
const { links, local, session } = await page.evaluate(() => ({
|
|
145
|
+
links: [...document.querySelectorAll("a[href]")].map((a) => a.href),
|
|
146
|
+
local: Object.keys(localStorage),
|
|
147
|
+
session: Object.keys(sessionStorage)
|
|
148
|
+
}));
|
|
149
|
+
for (const key2 of local) found.set(`l:${key2}`, { name: key2, duration: "persistent", storage: "localStorage" });
|
|
150
|
+
for (const key2 of session) found.set(`s:${key2}`, { name: key2, duration: "session", storage: "sessionStorage" });
|
|
151
|
+
for (const link of links) {
|
|
152
|
+
const u = new URL(link);
|
|
153
|
+
u.hash = "";
|
|
154
|
+
if (u.origin === origin && !seen.has(u.href) && !/\.(pdf|zip|jpe?g|png|webp|svg|mp4)$/i.test(u.pathname)) {
|
|
155
|
+
seen.add(u.href);
|
|
156
|
+
queue.push(u.href);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const c of await context.cookies()) {
|
|
161
|
+
const domain = c.domain.replace(/^\./, "");
|
|
162
|
+
const own = host === domain || host.endsWith(`.${domain}`);
|
|
163
|
+
found.set(`c:${c.name}`, { name: c.name, duration: durationOf(c.expires), ...own ? {} : { host: domain } });
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
await browser.close();
|
|
167
|
+
}
|
|
168
|
+
return [...found.values()].filter((c) => c.name !== cookieName && !matches(trackerNames, c.name)).filter((c) => !c.host || !trackerHosts.some((t) => c.host === t || c.host.endsWith(`.${t}`))).map((c) => ({ ...c, purpose: purposeOf(c.name) ?? { en: "", fi: "", sv: "" } }));
|
|
169
|
+
}
|
|
170
|
+
var key = (c) => `${c.storage ?? "cookie"}:${c.name}`;
|
|
171
|
+
function merge(existing, found) {
|
|
172
|
+
const old = new Map(existing.map((c) => [key(c), c]));
|
|
173
|
+
const now = new Set(found.map(key));
|
|
174
|
+
const added = found.filter((c) => !old.has(key(c)));
|
|
175
|
+
const missing = existing.filter((c) => !now.has(key(c)));
|
|
176
|
+
const list = [
|
|
177
|
+
...existing.map((c) => {
|
|
178
|
+
const f = found.find((x) => key(x) === key(c));
|
|
179
|
+
return f ? { ...c, duration: f.duration } : c;
|
|
180
|
+
}),
|
|
181
|
+
...added
|
|
182
|
+
];
|
|
183
|
+
return { list, added, missing };
|
|
184
|
+
}
|
|
185
|
+
export {
|
|
186
|
+
durationOf,
|
|
187
|
+
merge,
|
|
188
|
+
purposeOf,
|
|
189
|
+
scan
|
|
190
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { KeksiConfig, Locale } from './types.js';
|
|
2
|
+
interface Options {
|
|
3
|
+
/** Defaults to the page's `<html lang>`, then `fi`. */
|
|
4
|
+
lang?: Locale;
|
|
5
|
+
nonce?: string;
|
|
6
|
+
}
|
|
7
|
+
type Resolve = (event: any, opts?: {
|
|
8
|
+
transformPageChunk?: (input: {
|
|
9
|
+
html: string;
|
|
10
|
+
done: boolean;
|
|
11
|
+
}) => string;
|
|
12
|
+
}) => any;
|
|
13
|
+
/** SvelteKit `handle` hook that renders keksi right after `<body>`, outside the Svelte root. */
|
|
14
|
+
export declare function keksiHandle(config: KeksiConfig, { lang, nonce }?: Options): ({ event, resolve }: {
|
|
15
|
+
event: any;
|
|
16
|
+
resolve: Resolve;
|
|
17
|
+
}) => any;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
purpose: "Tarkoitus",
|
|
28
|
+
setBy: "Asettaja",
|
|
29
|
+
session: "istunto",
|
|
30
|
+
persistent: "kunnes poistetaan",
|
|
31
|
+
y: "vuosi|vuotta",
|
|
32
|
+
mo: "kuukausi|kuukautta",
|
|
33
|
+
d: "p\xE4iv\xE4|p\xE4iv\xE4\xE4",
|
|
34
|
+
min: "minuutti|minuuttia",
|
|
35
|
+
ytText: "Video ladataan YouTubesta. YouTube (Google) asettaa ev\xE4steit\xE4 ja voi siirt\xE4\xE4 tietoja Yhdysvaltoihin.",
|
|
36
|
+
ytButton: "N\xE4yt\xE4 video",
|
|
37
|
+
ytOpen: "Avaa YouTubessa"
|
|
38
|
+
};
|
|
39
|
+
var sv = {
|
|
40
|
+
title: "Kakor",
|
|
41
|
+
intro: "Ut\xF6ver n\xF6dv\xE4ndiga kakor anv\xE4nder vi f\xF6ljande tj\xE4nster endast om du ger ditt samtycke:",
|
|
42
|
+
transfer: "Tj\xE4nsteleverant\xF6rerna kan \xF6verf\xF6ra uppgifter till USA. Du kan \xE4ndra ditt val n\xE4r som helst.",
|
|
43
|
+
more: "L\xE4s mer",
|
|
44
|
+
accept: "Godk\xE4nn alla",
|
|
45
|
+
reject: "Avvisa alla",
|
|
46
|
+
settings: "Inst\xE4llningar",
|
|
47
|
+
settingsTitle: "Inst\xE4llningar f\xF6r kakor",
|
|
48
|
+
save: "Spara val",
|
|
49
|
+
close: "St\xE4ng",
|
|
50
|
+
alwaysOn: "Alltid p\xE5",
|
|
51
|
+
necessary: "N\xF6dv\xE4ndiga",
|
|
52
|
+
necessaryInfo: "N\xF6dv\xE4ndiga f\xF6r att webbplatsen ska fungera, till exempel f\xF6r att spara ditt val. Kr\xE4ver inget samtycke.",
|
|
53
|
+
analytics: "Analys",
|
|
54
|
+
analyticsInfo: "Vi m\xE4ter hur webbplatsen anv\xE4nds f\xF6r att kunna utveckla den.",
|
|
55
|
+
marketing: "Marknadsf\xF6ring",
|
|
56
|
+
marketingInfo: "Riktad annonsering och m\xE4tning av annonsernas effekt samt inb\xE4ddat inneh\xE5ll, till exempel videor.",
|
|
57
|
+
providers: "Tj\xE4nsteleverant\xF6rer",
|
|
58
|
+
thisSite: "Denna webbplats",
|
|
59
|
+
consentPurpose: "Sparar ditt val av kakor.",
|
|
60
|
+
privacyPolicy: "Dataskyddsbeskrivning",
|
|
61
|
+
dpf: "Uppgifter kan \xF6verf\xF6ras till USA med st\xF6d av ramverket f\xF6r dataskydd mellan EU och USA (DPF).",
|
|
62
|
+
name: "Namn",
|
|
63
|
+
duration: "Varaktighet",
|
|
64
|
+
purpose: "Syfte",
|
|
65
|
+
setBy: "S\xE4tts av",
|
|
66
|
+
session: "session",
|
|
67
|
+
persistent: "tills den raderas",
|
|
68
|
+
y: "\xE5r|\xE5r",
|
|
69
|
+
mo: "m\xE5nad|m\xE5nader",
|
|
70
|
+
d: "dag|dagar",
|
|
71
|
+
min: "minut|minuter",
|
|
72
|
+
ytText: "Videon laddas fr\xE5n YouTube. YouTube (Google) s\xE4tter kakor och kan \xF6verf\xF6ra uppgifter till USA.",
|
|
73
|
+
ytButton: "Visa video",
|
|
74
|
+
ytOpen: "\xD6ppna p\xE5 YouTube"
|
|
75
|
+
};
|
|
76
|
+
var en = {
|
|
77
|
+
title: "Cookies",
|
|
78
|
+
intro: "Besides necessary cookies, we use the following services only if you allow it:",
|
|
79
|
+
transfer: "The providers may transfer data to the United States. You can change your choice at any time.",
|
|
80
|
+
more: "Read more",
|
|
81
|
+
accept: "Accept all",
|
|
82
|
+
reject: "Reject all",
|
|
83
|
+
settings: "Settings",
|
|
84
|
+
settingsTitle: "Cookie settings",
|
|
85
|
+
save: "Save choices",
|
|
86
|
+
close: "Close",
|
|
87
|
+
alwaysOn: "Always on",
|
|
88
|
+
necessary: "Necessary",
|
|
89
|
+
necessaryInfo: "Needed for the site to work, such as storing your cookie choice. No consent needed.",
|
|
90
|
+
analytics: "Analytics",
|
|
91
|
+
analyticsInfo: "We measure how the site is used so we can improve it.",
|
|
92
|
+
marketing: "Marketing",
|
|
93
|
+
marketingInfo: "Ad targeting and measurement, and embedded content such as videos.",
|
|
94
|
+
providers: "Providers",
|
|
95
|
+
thisSite: "This site",
|
|
96
|
+
consentPurpose: "Stores your cookie choice.",
|
|
97
|
+
privacyPolicy: "Privacy policy",
|
|
98
|
+
dpf: "Data may be transferred to the United States under the EU\u2013US Data Privacy Framework (DPF).",
|
|
99
|
+
name: "Name",
|
|
100
|
+
duration: "Duration",
|
|
101
|
+
purpose: "Purpose",
|
|
102
|
+
setBy: "Set by",
|
|
103
|
+
session: "session",
|
|
104
|
+
persistent: "until deleted",
|
|
105
|
+
y: "year|years",
|
|
106
|
+
mo: "month|months",
|
|
107
|
+
d: "day|days",
|
|
108
|
+
min: "minute|minutes",
|
|
109
|
+
ytText: "The video loads from YouTube. YouTube (Google) sets cookies and may transfer data to the United States.",
|
|
110
|
+
ytButton: "Show video",
|
|
111
|
+
ytOpen: "Open on YouTube"
|
|
112
|
+
};
|
|
113
|
+
var texts = { fi, sv, en };
|
|
114
|
+
|
|
115
|
+
// src/providers.ts
|
|
116
|
+
var GOOGLE = { company: "Google Ireland Limited", privacyPolicy: "https://policies.google.com/privacy", transfer: "dpf" };
|
|
117
|
+
var META = { company: "Meta Platforms Ireland Limited", privacyPolicy: "https://www.facebook.com/privacy/policy/", transfer: "dpf" };
|
|
118
|
+
var providers = [
|
|
119
|
+
{
|
|
120
|
+
id: "ga",
|
|
121
|
+
name: "Google Analytics",
|
|
122
|
+
...GOOGLE,
|
|
123
|
+
category: "analytics",
|
|
124
|
+
purpose: {
|
|
125
|
+
fi: "K\xE4vij\xE4tilastot: k\xE4vij\xE4m\xE4\xE4r\xE4t, liikenteen l\xE4hteet ja sivujen k\xE4ytt\xF6.",
|
|
126
|
+
sv: "Bes\xF6ksstatistik: antal bes\xF6kare, trafikk\xE4llor och hur sidorna anv\xE4nds.",
|
|
127
|
+
en: "Visitor statistics: visitor numbers, traffic sources and page usage."
|
|
128
|
+
},
|
|
129
|
+
cookies: [
|
|
130
|
+
{ name: "_ga", duration: "2y" },
|
|
131
|
+
{ name: "_ga_*", duration: "2y" }
|
|
132
|
+
],
|
|
133
|
+
hosts: ["google-analytics.com", "analytics.google.com", "googletagmanager.com"]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "googleAds",
|
|
137
|
+
name: "Google Ads",
|
|
138
|
+
...GOOGLE,
|
|
139
|
+
category: "marketing",
|
|
140
|
+
purpose: {
|
|
141
|
+
fi: "Mainonnan tehon mittaaminen (konversiot) ja mainosten kohdentaminen sivuston k\xE4vij\xF6ille.",
|
|
142
|
+
sv: "M\xE4tning av annonsernas effekt (konverteringar) och riktad annonsering till bes\xF6kare.",
|
|
143
|
+
en: "Measuring ad performance (conversions) and showing ads to site visitors."
|
|
144
|
+
},
|
|
145
|
+
cookies: [
|
|
146
|
+
{ name: "_gcl_au", duration: "90d" },
|
|
147
|
+
{ name: "_gcl_aw", duration: "90d" },
|
|
148
|
+
{ name: "_gcl_dc", duration: "90d" },
|
|
149
|
+
{ name: "_gcl_gs", duration: "90d" },
|
|
150
|
+
{ name: "_gac_*", duration: "90d" },
|
|
151
|
+
{ name: "_gcl_ls", duration: "persistent", storage: "localStorage" },
|
|
152
|
+
{ name: "IDE", duration: "13mo", host: "doubleclick.net" },
|
|
153
|
+
{ name: "DSID", duration: "14d", host: "doubleclick.net" },
|
|
154
|
+
{ name: "test_cookie", duration: "15min", host: "doubleclick.net" }
|
|
155
|
+
],
|
|
156
|
+
hosts: ["googletagmanager.com", "googleadservices.com", "doubleclick.net", "googlesyndication.com"]
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "meta",
|
|
160
|
+
name: "Meta Pixel",
|
|
161
|
+
...META,
|
|
162
|
+
category: "marketing",
|
|
163
|
+
purpose: {
|
|
164
|
+
fi: "Mainonnan tehon mittaaminen ja mainosten kohdentaminen Facebookissa ja Instagramissa.",
|
|
165
|
+
sv: "M\xE4tning av annonsernas effekt och riktad annonsering p\xE5 Facebook och Instagram.",
|
|
166
|
+
en: "Measuring ad performance and targeting ads on Facebook and Instagram."
|
|
167
|
+
},
|
|
168
|
+
cookies: [
|
|
169
|
+
{ name: "_fbp", duration: "90d" },
|
|
170
|
+
{ name: "_fbc", duration: "90d" },
|
|
171
|
+
{ name: "fr", duration: "90d", host: "facebook.com" }
|
|
172
|
+
],
|
|
173
|
+
hosts: ["facebook.net", "facebook.com"]
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "youtube",
|
|
177
|
+
name: "YouTube",
|
|
178
|
+
...GOOGLE,
|
|
179
|
+
category: "marketing",
|
|
180
|
+
purpose: {
|
|
181
|
+
fi: "Upotettujen videoiden toistaminen. YouTube voi k\xE4ytt\xE4\xE4 tietoja mainontaan.",
|
|
182
|
+
sv: "Uppspelning av inb\xE4ddade videor. YouTube kan anv\xE4nda uppgifterna f\xF6r annonsering.",
|
|
183
|
+
en: "Playing embedded videos. YouTube may use the data for advertising."
|
|
184
|
+
},
|
|
185
|
+
cookies: [
|
|
186
|
+
{ name: "YSC", duration: "session", host: "youtube.com" },
|
|
187
|
+
{ name: "VISITOR_INFO1_LIVE", duration: "6mo", host: "youtube.com" },
|
|
188
|
+
{ name: "VISITOR_PRIVACY_METADATA", duration: "6mo", host: "youtube.com" },
|
|
189
|
+
{ name: "__Secure-YNID", duration: "6mo", host: "youtube.com" },
|
|
190
|
+
{ name: "__Secure-ROLLOUT_TOKEN", duration: "6mo", host: "youtube.com" },
|
|
191
|
+
{ name: "PREF", duration: "8mo", host: "youtube.com" },
|
|
192
|
+
{ name: "yt-remote-*, ytidb::*, yt-player-*", duration: "persistent", host: "youtube-nocookie.com", storage: "localStorage" }
|
|
193
|
+
],
|
|
194
|
+
hosts: ["youtube.com", "youtube-nocookie.com", "ytimg.com", "googlevideo.com"]
|
|
195
|
+
}
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
// src/config.ts
|
|
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 sections(config, lang, h, dialog) {
|
|
285
|
+
const t = textsFor(config, lang);
|
|
286
|
+
const active = activeProviders(config);
|
|
287
|
+
const { name, days } = runtimeData(config, lang);
|
|
288
|
+
const local = (v) => typeof v === "object" ? v[lang] ?? Object.values(v).find(Boolean) : v;
|
|
289
|
+
const site = {
|
|
290
|
+
name: t.thisSite,
|
|
291
|
+
cookies: [
|
|
292
|
+
{ name, duration: days % 365 ? `${days}d` : `${days / 365}y`, purpose: t.consentPurpose },
|
|
293
|
+
...(config.necessary ?? []).map((c) => ({ ...c, purpose: local(c.purpose) }))
|
|
294
|
+
]
|
|
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.purpose ? `<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 purpose = cookies.some((c) => c.purpose);
|
|
308
|
+
const rows = cookies.map(
|
|
309
|
+
(c) => `<tr><td><code>${e(c.name)}</code>${c.storage ? ` (${c.storage})` : ""}</td>` + (purpose ? `<td>${e(c.purpose ?? "")}</td>` : "") + `<td>${e(duration(c.duration, t))}</td><td>${e(c.host ?? t.thisSite)}</td></tr>`
|
|
310
|
+
).join("");
|
|
311
|
+
return `<table><thead><tr><th scope="col">${e(t.name)}</th>` + (purpose ? `<th scope="col">${e(t.purpose)}</th>` : "") + `<th scope="col">${e(t.duration)}</th><th scope="col">${e(t.setBy)}</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
312
|
+
}
|
|
313
|
+
function duration(d, t) {
|
|
314
|
+
if (d === "session" || d === "persistent") return t[d];
|
|
315
|
+
const m = d.match(/^(\d+)(y|mo|d|min)$/);
|
|
316
|
+
if (!m) return d;
|
|
317
|
+
const [, n, unit] = m;
|
|
318
|
+
const [one, many] = t[unit].split("|");
|
|
319
|
+
return `${n} ${n === "1" ? one : many}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/sveltekit.ts
|
|
323
|
+
var LOCALES = ["fi", "sv", "en"];
|
|
324
|
+
function keksiHandle(config, { lang, nonce } = {}) {
|
|
325
|
+
const cache = /* @__PURE__ */ new Map();
|
|
326
|
+
const render = (l) => cache.get(l) ?? cache.set(l, renderKeksi(config, { lang: l, nonce })).get(l);
|
|
327
|
+
return ({ event, resolve }) => {
|
|
328
|
+
let pageLang = lang;
|
|
329
|
+
return resolve(event, {
|
|
330
|
+
transformPageChunk: ({ html }) => {
|
|
331
|
+
pageLang ??= LOCALES.find((l) => new RegExp(`<html[^>]*\\slang="${l}`).test(html));
|
|
332
|
+
return html.replace(/<body[^>]*>/, (body) => body + render(pageLang ?? "fi"));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export {
|
|
338
|
+
keksiHandle
|
|
339
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -20,8 +20,19 @@ export interface KeksiConfig {
|
|
|
20
20
|
domain?: string;
|
|
21
21
|
days?: number;
|
|
22
22
|
};
|
|
23
|
+
/** Cookies your site or framework sets without consent, usually from `keksi scan`. Listed under "Necessary". */
|
|
24
|
+
necessary?: NecessaryCookie[];
|
|
23
25
|
texts?: Partial<Record<Locale, Partial<Texts>>>;
|
|
24
26
|
}
|
|
27
|
+
export interface NecessaryCookie {
|
|
28
|
+
name: string;
|
|
29
|
+
/** A `Duration`; plain string so JSON imports type-check. */
|
|
30
|
+
duration: string;
|
|
31
|
+
purpose?: string | Partial<Record<Locale, string>>;
|
|
32
|
+
host?: string;
|
|
33
|
+
/** `localStorage` or `sessionStorage`; omitted for cookies. */
|
|
34
|
+
storage?: string;
|
|
35
|
+
}
|
|
25
36
|
/** `session`, `persistent`, or a number with unit: `2y`, `13mo`, `90d`, `15min`. */
|
|
26
37
|
export type Duration = 'session' | 'persistent' | `${number}${'y' | 'mo' | 'd' | 'min'}`;
|
|
27
38
|
export interface CookieInfo {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keksi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Lean cookie consent for Google and Meta trackers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"node": ">=22.12"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"dist"
|
|
12
|
+
"dist",
|
|
13
|
+
"astro"
|
|
13
14
|
],
|
|
14
15
|
"exports": {
|
|
15
16
|
".": {
|
|
@@ -19,6 +20,18 @@
|
|
|
19
20
|
"./playwright": {
|
|
20
21
|
"types": "./dist/playwright.d.ts",
|
|
21
22
|
"default": "./dist/playwright.js"
|
|
23
|
+
},
|
|
24
|
+
"./astro": {
|
|
25
|
+
"types": "./astro/index.d.ts",
|
|
26
|
+
"default": "./astro/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./sveltekit": {
|
|
29
|
+
"types": "./dist/sveltekit.d.ts",
|
|
30
|
+
"default": "./dist/sveltekit.js"
|
|
31
|
+
},
|
|
32
|
+
"./scan": {
|
|
33
|
+
"types": "./dist/scan.d.ts",
|
|
34
|
+
"default": "./dist/scan.js"
|
|
22
35
|
}
|
|
23
36
|
},
|
|
24
37
|
"peerDependencies": {
|
|
@@ -37,6 +50,9 @@
|
|
|
37
50
|
"typescript": "^6.0.3",
|
|
38
51
|
"vitest": "^5.0.5"
|
|
39
52
|
},
|
|
53
|
+
"bin": {
|
|
54
|
+
"keksi": "./dist/cli.js"
|
|
55
|
+
},
|
|
40
56
|
"scripts": {
|
|
41
57
|
"build": "node scripts/build.mjs",
|
|
42
58
|
"test": "vitest run",
|