@inneropen/marvin-astro 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';