@inneropen/marvin-astro 2.0.0 → 2.1.1
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 +114 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +117 -3
- package/dist/index.js.map +1 -1
- package/dist/types-4VdGN9e_.d.ts +243 -0
- package/dist/types.d.ts +3 -162
- package/package.json +1 -1
- package/src/components/ConsentBanner.astro +87 -0
- package/src/components/ConsentBannerView.astro +104 -0
- package/src/components/CookieSettingsLink.astro +28 -0
- package/src/components/CookieTable.astro +99 -0
- package/src/components/PrivacyPolicy.astro +118 -0
- package/src/components/ProcessorTable.astro +64 -0
- package/src/components/consent.css +192 -0
- package/src/components/consent.ts +246 -0
- package/src/components/index.ts +12 -0
- package/src/components/privacy-text.ts +149 -0
- package/src/components/privacy.css +150 -0
- package/src/index.ts +8 -0
- package/src/privacy.ts +210 -0
- package/src/site.ts +8 -0
- package/src/types.ts +15 -0
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy & cookie disclosures: the published `site.privacy` shape, read tolerantly.
|
|
3
|
+
*
|
|
4
|
+
* Marvin keeps the structured facts a privacy/cookie policy page needs (who the controller is,
|
|
5
|
+
* which processors handle visitor data, which cookies and storage entries the site sets). The
|
|
6
|
+
* components in this package render them and, when any entry is not strictly necessary, run a
|
|
7
|
+
* consent banner.
|
|
8
|
+
*
|
|
9
|
+
* Declared here rather than imported from `@inneropen/marvin-sdk` so the package works with SDKs
|
|
10
|
+
* that predate `site.privacy`. Both the published camelCase keys and snake_case are accepted, and
|
|
11
|
+
* a malformed entry is dropped rather than failing the site build.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { asRecord, asString } from './normalize.js';
|
|
15
|
+
|
|
16
|
+
export type CookieCategory = 'necessary' | 'preferences' | 'analytics' | 'marketing';
|
|
17
|
+
|
|
18
|
+
/** The categories a visitor can switch on; `necessary` is always on and never asked. */
|
|
19
|
+
export type ConsentCategory = Exclude<CookieCategory, 'necessary'>;
|
|
20
|
+
|
|
21
|
+
export const COOKIE_CATEGORIES: readonly CookieCategory[] = [
|
|
22
|
+
'necessary',
|
|
23
|
+
'preferences',
|
|
24
|
+
'analytics',
|
|
25
|
+
'marketing',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const CONSENT_CATEGORIES: readonly ConsentCategory[] = [
|
|
29
|
+
'preferences',
|
|
30
|
+
'analytics',
|
|
31
|
+
'marketing',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Who is responsible for the site's data (the GDPR "controller"). */
|
|
35
|
+
export type PrivacyController = {
|
|
36
|
+
legalName: string;
|
|
37
|
+
contactEmail?: string;
|
|
38
|
+
address?: string;
|
|
39
|
+
euRepresentative?: string;
|
|
40
|
+
dpoEmail?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A third party that handles visitor data on the site's behalf. */
|
|
44
|
+
export type PrivacyProcessor = {
|
|
45
|
+
name: string;
|
|
46
|
+
purpose?: string;
|
|
47
|
+
dataCategories: string[];
|
|
48
|
+
region?: string;
|
|
49
|
+
transferBasis?: string;
|
|
50
|
+
dpaUrl?: string;
|
|
51
|
+
privacyUrl?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A cookie (or similar storage entry) the site sets. */
|
|
55
|
+
export type PrivacyCookie = {
|
|
56
|
+
name: string;
|
|
57
|
+
provider: string;
|
|
58
|
+
purpose: string;
|
|
59
|
+
duration?: string;
|
|
60
|
+
category: CookieCategory;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type PrivacyConsent = {
|
|
64
|
+
policyEntrySlug?: string;
|
|
65
|
+
cookiePolicyEntrySlug?: string;
|
|
66
|
+
/** ISO date (`YYYY-MM-DD`) the disclosures were last checked. */
|
|
67
|
+
lastReviewed?: string;
|
|
68
|
+
/** A manual re-prompt bump: changing it asks every visitor again. */
|
|
69
|
+
version?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type SitePrivacy = {
|
|
73
|
+
controller?: PrivacyController;
|
|
74
|
+
processors: PrivacyProcessor[];
|
|
75
|
+
cookies: PrivacyCookie[];
|
|
76
|
+
consent: PrivacyConsent;
|
|
77
|
+
/** True when any cookie is not `necessary`: those need prior consent. */
|
|
78
|
+
bannerRequired: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Key a field by its camelCase name, falling back to snake_case. */
|
|
82
|
+
function field(record: Record<string, unknown>, camel: string): unknown {
|
|
83
|
+
if (record[camel] !== undefined) return record[camel];
|
|
84
|
+
return record[camel.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function text(record: Record<string, unknown>, camel: string): string | undefined {
|
|
88
|
+
const value = asString(field(record, camel));
|
|
89
|
+
return value?.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function records(value: unknown): Record<string, unknown>[] {
|
|
93
|
+
return Array.isArray(value)
|
|
94
|
+
? value.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
|
95
|
+
: [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readController(raw: unknown): PrivacyController | undefined {
|
|
99
|
+
const value = asRecord(raw);
|
|
100
|
+
const legalName = text(value, 'legalName');
|
|
101
|
+
if (!legalName) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
legalName,
|
|
104
|
+
contactEmail: text(value, 'contactEmail'),
|
|
105
|
+
address: text(value, 'address'),
|
|
106
|
+
euRepresentative: text(value, 'euRepresentative'),
|
|
107
|
+
dpoEmail: text(value, 'dpoEmail'),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readProcessor(value: Record<string, unknown>): PrivacyProcessor | undefined {
|
|
112
|
+
const name = text(value, 'name');
|
|
113
|
+
if (!name) return undefined;
|
|
114
|
+
const categories = field(value, 'dataCategories');
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
purpose: text(value, 'purpose'),
|
|
118
|
+
dataCategories: Array.isArray(categories)
|
|
119
|
+
? categories.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
|
|
120
|
+
: [],
|
|
121
|
+
region: text(value, 'region'),
|
|
122
|
+
transferBasis: text(value, 'transferBasis'),
|
|
123
|
+
dpaUrl: text(value, 'dpaUrl'),
|
|
124
|
+
privacyUrl: text(value, 'privacyUrl'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readCookie(value: Record<string, unknown>): PrivacyCookie | undefined {
|
|
129
|
+
const name = text(value, 'name');
|
|
130
|
+
if (!name) return undefined;
|
|
131
|
+
const category = text(value, 'category');
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
provider: text(value, 'provider') ?? '',
|
|
135
|
+
purpose: text(value, 'purpose') ?? '',
|
|
136
|
+
duration: text(value, 'duration'),
|
|
137
|
+
// A category a newer server invented is treated as the strictest optional one, never as
|
|
138
|
+
// `necessary`: it must be asked about before anything it covers loads.
|
|
139
|
+
category: COOKIE_CATEGORIES.includes(category as CookieCategory)
|
|
140
|
+
? (category as CookieCategory)
|
|
141
|
+
: 'marketing',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when any listed cookie needs consent. */
|
|
146
|
+
export function cookiesNeedConsent(cookies: readonly Pick<PrivacyCookie, 'category'>[]): boolean {
|
|
147
|
+
return cookies.some((cookie) => cookie.category !== 'necessary');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Normalize `site.privacy`; `undefined` when the server doesn't send it (or sends a non-object).
|
|
152
|
+
* `bannerRequired` is taken from the payload when it is a boolean and computed from the cookies
|
|
153
|
+
* otherwise.
|
|
154
|
+
*/
|
|
155
|
+
export function sitePrivacy(raw: unknown): SitePrivacy | undefined {
|
|
156
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
157
|
+
const value = raw as Record<string, unknown>;
|
|
158
|
+
const consent = asRecord(field(value, 'consent'));
|
|
159
|
+
|
|
160
|
+
const cookies = records(value.cookies)
|
|
161
|
+
.map(readCookie)
|
|
162
|
+
.filter((cookie): cookie is PrivacyCookie => cookie !== undefined);
|
|
163
|
+
const declared = field(value, 'bannerRequired');
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
controller: readController(value.controller),
|
|
167
|
+
processors: records(value.processors)
|
|
168
|
+
.map(readProcessor)
|
|
169
|
+
.filter((processor): processor is PrivacyProcessor => processor !== undefined),
|
|
170
|
+
cookies,
|
|
171
|
+
consent: {
|
|
172
|
+
policyEntrySlug: text(consent, 'policyEntrySlug'),
|
|
173
|
+
cookiePolicyEntrySlug: text(consent, 'cookiePolicyEntrySlug'),
|
|
174
|
+
lastReviewed: text(consent, 'lastReviewed'),
|
|
175
|
+
version: text(consent, 'version'),
|
|
176
|
+
},
|
|
177
|
+
bannerRequired: typeof declared === 'boolean' ? declared : cookiesNeedConsent(cookies),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 32-bit FNV-1a of a string, as 8 lowercase hex digits. */
|
|
182
|
+
function fnv1a(input: string): string {
|
|
183
|
+
let hash = 0x811c9dc5;
|
|
184
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
185
|
+
hash ^= input.charCodeAt(index);
|
|
186
|
+
hash = Math.imul(hash, 0x01000193);
|
|
187
|
+
}
|
|
188
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The version a visitor's choice is bound to: FNV-1a over the sorted, non-necessary
|
|
193
|
+
* `category|name|provider` triples (plus `consent.version`, a manual bump). Adding, removing or
|
|
194
|
+
* re-categorising a cookie changes it and asks everyone again; rewording a purpose, changing a
|
|
195
|
+
* duration, or moving `lastReviewed` does not.
|
|
196
|
+
*/
|
|
197
|
+
export function consentVersion(privacy: SitePrivacy | undefined): string {
|
|
198
|
+
const triples = (privacy?.cookies ?? [])
|
|
199
|
+
.filter((cookie) => cookie.category !== 'necessary')
|
|
200
|
+
.map((cookie) => `${cookie.category}|${cookie.name}|${cookie.provider}`)
|
|
201
|
+
.sort();
|
|
202
|
+
const bump = privacy?.consent.version;
|
|
203
|
+
return fnv1a(bump ? [...triples, `v:${bump}`].join('\n') : triples.join('\n'));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The optional categories that appear in the cookie list, in display order. */
|
|
207
|
+
export function consentCategories(privacy: SitePrivacy | undefined): ConsentCategory[] {
|
|
208
|
+
const present = new Set((privacy?.cookies ?? []).map((cookie) => cookie.category));
|
|
209
|
+
return CONSENT_CATEGORIES.filter((category) => present.has(category));
|
|
210
|
+
}
|
package/src/site.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { MarvinSite, SiteConfiguration } from '@inneropen/marvin-sdk';
|
|
11
11
|
import { siteEmbeds } from './embeds.js';
|
|
12
|
+
import { sitePrivacy } from './privacy.js';
|
|
12
13
|
import type { MarvinFetcher } from './fetch.js';
|
|
13
14
|
import { asRecord, asString } from './normalize.js';
|
|
14
15
|
import type { ApiSeo, ApiSite } from './types.js';
|
|
@@ -131,6 +132,13 @@ function transformMarvinSite(marvinSite: MarvinSite, fallback: ApiSite): ApiSite
|
|
|
131
132
|
embeds: siteEmbeds(
|
|
132
133
|
(config as { embeds?: unknown }).embeds ?? (marvinSite as { embeds?: unknown }).embeds
|
|
133
134
|
),
|
|
135
|
+
// Typed `config.privacy` when the SDK declares it (SDK 7.2+), else the raw blob under
|
|
136
|
+
// `metadata.privacy`; this package's SDK peer range predates the typed field, so read both.
|
|
137
|
+
privacy: sitePrivacy(
|
|
138
|
+
(config as { privacy?: unknown }).privacy ??
|
|
139
|
+
(marvinSite as { privacy?: unknown }).privacy ??
|
|
140
|
+
metadata.privacy
|
|
141
|
+
),
|
|
134
142
|
metadata,
|
|
135
143
|
};
|
|
136
144
|
}
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { MarvinSiteEmbeds } from './embeds.js';
|
|
10
|
+
import type { SitePrivacy } from './privacy.js';
|
|
10
11
|
import type {
|
|
11
12
|
CollectionEntry,
|
|
12
13
|
Entry,
|
|
@@ -111,6 +112,11 @@ export type ApiSite = {
|
|
|
111
112
|
* Absent when the server predates media embeds.
|
|
112
113
|
*/
|
|
113
114
|
embeds?: MarvinSiteEmbeds;
|
|
115
|
+
/**
|
|
116
|
+
* Privacy & cookie disclosures (`site.privacy`), normalized by `sitePrivacy`. Absent when the
|
|
117
|
+
* server predates them or none are configured.
|
|
118
|
+
*/
|
|
119
|
+
privacy?: SitePrivacy;
|
|
114
120
|
metadata: Record<string, unknown>;
|
|
115
121
|
};
|
|
116
122
|
|
|
@@ -186,3 +192,12 @@ export type {
|
|
|
186
192
|
MarvinEmbedStatus,
|
|
187
193
|
MarvinSiteEmbeds,
|
|
188
194
|
} from './embeds.js';
|
|
195
|
+
export type {
|
|
196
|
+
ConsentCategory,
|
|
197
|
+
CookieCategory,
|
|
198
|
+
PrivacyConsent,
|
|
199
|
+
PrivacyController,
|
|
200
|
+
PrivacyCookie,
|
|
201
|
+
PrivacyProcessor,
|
|
202
|
+
SitePrivacy,
|
|
203
|
+
} from './privacy.js';
|