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 CHANGED
@@ -1,17 +1,22 @@
1
1
  # keksi
2
2
 
3
- Lean cookie consent for our client sites: Google Analytics 4, Google Ads, Meta Pixel and YouTube embeds, in Finnish,
4
- Swedish and English.
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; 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.
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
- - 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.
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
- Render it right after `<body>`, and give the footer a way to reopen the settings:
33
+ **Astro:** add the component right after `<body>` in your layout.
29
34
 
30
35
  ```astro
31
36
  ---
32
- // Astro layout
33
- import { renderKeksi } from 'keksi';
37
+ import { Keksi } from 'keksi/astro';
34
38
  import config from '../../keksi.config';
35
39
  ---
36
40
  <body>
37
- <Fragment set:html={renderKeksi(config, { lang: 'fi' })} />
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
- // 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';
49
+ // src/hooks.server.ts
50
+ import { keksiHandle } from 'keksi/sveltekit';
47
51
  import config from '../keksi.config';
48
52
 
49
- const keksi = renderKeksi(config, { lang: 'fi' });
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
- export const handle: Handle = ({ event, resolve }) =>
52
- resolve(event, { transformPageChunk: ({ html }) => html.replace(/<body([^>]*)>/, `<body$1>${keksi}`) });
58
+ ```html
59
+ <button type="button" data-keksi-open>Evästeasetukset</button>
53
60
  ```
54
61
 
55
- Policy page:
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
- ```astro
58
- <Fragment set:html={cookieTable(config, { lang: 'fi' })} />
65
+ ```sh
66
+ npx keksi scan https://staging.example.fi
59
67
  ```
60
68
 
61
- In SvelteKit, return `cookieTable(...)` from a prerendered `+page.server.ts` and render it with `{@html}`.
69
+ It writes `keksi.cookies.json`, with purposes for common cookies filled in. Write the rest, then import the file:
62
70
 
63
- ## Using the trackers
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
- `gtag()` and `fbq()` exist from the start, so page code can call them at any time:
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 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.
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
- 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.
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 to Google, Meta or YouTube, or on stored data
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
- ## 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
133
+ ## Customizing
132
134
 
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.
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
- ## What the law requires and how keksi meets it
143
+ ## Legal basis
138
144
 
139
145
  | Requirement | Basis | keksi |
140
146
  |---|---|---|
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 |
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
- ## 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`.
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 # dist/, prints the runtime size
158
- pnpm test # unit tests
159
- pnpm test:e2e # both examples in Chromium
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 })} />
@@ -0,0 +1,2 @@
1
+ export { default as Keksi } from './Keksi.astro';
2
+ export { default as CookieTable } from './CookieTable.astro';
package/astro/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { default as Keksi } from './Keksi.astro';
2
+ export { default as CookieTable } from './CookieTable.astro';
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
@@ -24,6 +24,7 @@ declare const fi: {
24
24
  dpf: string;
25
25
  name: string;
26
26
  duration: string;
27
+ purpose: string;
27
28
  setBy: string;
28
29
  session: string;
29
30
  persistent: string;
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
- purpose: t.consentPurpose,
294
- cookies: [{ name, duration: days % 365 ? `${days}d` : `${days / 365}y` }]
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}><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>`
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><td>${e(duration(c.duration, t))}</td><td>${e(c.host ?? t.thisSite)}</td></tr>`
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><th scope="col">${e(t.duration)}</th><th scope="col">${e(t.setBy)}</th></tr></thead><tbody>${rows}</tbody></table>`;
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 [, n, unit] = d.match(/^(\d+)(y|mo|d|min)$/);
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
  }
@@ -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.0",
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",