@inneropen/marvin-astro 1.4.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/README.md +120 -6
- package/dist/index.d.ts +6 -12
- package/dist/index.js +125 -42
- 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 +97 -0
- package/src/components/PrivacyPolicy.astro +118 -0
- package/src/components/ProcessorTable.astro +62 -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 +123 -0
- package/src/fetch.ts +0 -14
- package/src/index.ts +9 -1
- package/src/privacy.ts +210 -0
- package/src/site.ts +21 -49
- package/src/types.ts +18 -3
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
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Marvin's site configuration is optional almost everywhere; a template that had to guard every
|
|
5
5
|
* field would be unreadable. This resolves it once into an `ApiSite` where title, description,
|
|
6
|
-
* siteName, locale and timezone are always present, and
|
|
7
|
-
*
|
|
6
|
+
* siteName, locale and timezone are always present. Logo, favicon and SEO image are the URLs
|
|
7
|
+
* Marvin returns, used as-is.
|
|
8
8
|
*/
|
|
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
|
-
import { asRecord, asString
|
|
14
|
+
import { asRecord, asString } from './normalize.js';
|
|
14
15
|
import type { ApiSeo, ApiSite } from './types.js';
|
|
15
16
|
|
|
16
17
|
export type SiteOptions = {
|
|
@@ -131,12 +132,20 @@ 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
|
}
|
|
137
145
|
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
/** A usable image reference: an absolute URL, a root-relative path or a data URI. */
|
|
147
|
+
function isUrlLike(value: string): boolean {
|
|
148
|
+
return /^https?:\/\//.test(value) || value.startsWith('/') || value.startsWith('data:');
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
export type SiteLoader = {
|
|
@@ -155,18 +164,6 @@ export function createSiteLoader(fetcher: MarvinFetcher, options: SiteOptions =
|
|
|
155
164
|
let promise: Promise<ApiSite> | null = null;
|
|
156
165
|
let fromMarvin = false;
|
|
157
166
|
|
|
158
|
-
async function resolveAssetSlug(slug: string | undefined): Promise<string | undefined> {
|
|
159
|
-
if (!slug) return undefined;
|
|
160
|
-
return assetUrl(await fetcher.asset(slug));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Resolve a value only when it looks like a bare asset slug; a URL/path/data URI (or a slug with
|
|
164
|
-
// no matching asset) is returned unchanged.
|
|
165
|
-
async function resolveMaybeSlug(value: string | undefined): Promise<string | undefined> {
|
|
166
|
-
if (!value || !looksLikeAssetSlug(value)) return value;
|
|
167
|
-
return (await resolveAssetSlug(value)) ?? value;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
167
|
async function load(): Promise<ApiSite> {
|
|
171
168
|
const marvinSite = await fetcher.site();
|
|
172
169
|
fromMarvin = marvinSite !== null;
|
|
@@ -174,46 +171,21 @@ export function createSiteLoader(fetcher: MarvinFetcher, options: SiteOptions =
|
|
|
174
171
|
|
|
175
172
|
const site = transformMarvinSite(marvinSite, fallback);
|
|
176
173
|
|
|
177
|
-
// Brand-asset registry:
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
const brandPairs = Object.entries(asRecord(site.metadata.brand)).filter(
|
|
181
|
-
(entry): entry is [string, string] =>
|
|
182
|
-
typeof entry[1] === 'string' && entry[1].trim().length > 0
|
|
183
|
-
);
|
|
184
|
-
const resolved = await Promise.all(
|
|
185
|
-
brandPairs.map(async ([name, slug]) => [name, await resolveAssetSlug(slug)] as const)
|
|
186
|
-
);
|
|
174
|
+
// Brand-asset registry: every `brand.<name>` that is a URL (or root-relative path / data URI),
|
|
175
|
+
// used as-is. Marvin no longer serves assets by slug, so a bare slug can't be resolved here and
|
|
176
|
+
// is left out; `site.logo`, `site.favicon` and `site.seo.image` arrive already resolved.
|
|
187
177
|
const brand: Record<string, string> = {};
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
if (!url) continue;
|
|
191
|
-
brand[name] = url;
|
|
192
|
-
brandBySlug.set(brandPairs[index][1], url);
|
|
178
|
+
for (const [name, value] of Object.entries(asRecord(site.metadata.brand))) {
|
|
179
|
+
if (typeof value === 'string' && isUrlLike(value.trim())) brand[name] = value.trim();
|
|
193
180
|
}
|
|
194
181
|
|
|
195
|
-
// The og:image may be given as a bare asset slug too. It is usually one of the brand slugs
|
|
196
|
-
// (a monogram, a logo), so check what was just resolved before fetching it again.
|
|
197
|
-
const rawImage = site.seo.image;
|
|
198
|
-
const ogImage =
|
|
199
|
-
rawImage && looksLikeAssetSlug(rawImage)
|
|
200
|
-
? (brandBySlug.get(rawImage) ?? (await resolveAssetSlug(rawImage)))
|
|
201
|
-
: undefined;
|
|
202
|
-
|
|
203
|
-
// The top-level `site_logo` / `site_favicon` fields may be bare asset slugs too (like brand.*
|
|
204
|
-
// and og:image) — resolve them so they can reference an uploaded asset by slug. The brand-map
|
|
205
|
-
// aliases still win when present.
|
|
206
|
-
const logo = brand.logo ?? (await resolveMaybeSlug(site.logo));
|
|
207
|
-
const favicon = brand.favicon ?? (await resolveMaybeSlug(site.favicon));
|
|
208
|
-
|
|
209
182
|
return {
|
|
210
183
|
...site,
|
|
211
184
|
brand,
|
|
212
185
|
// Convenience aliases for common callers; all of these also live in `site.brand`.
|
|
213
|
-
logo,
|
|
214
|
-
favicon,
|
|
186
|
+
logo: brand.logo ?? site.logo,
|
|
187
|
+
favicon: brand.favicon ?? site.favicon,
|
|
215
188
|
seal: brand.seal ?? site.seal,
|
|
216
|
-
seo: { ...site.seo, image: ogImage ?? site.seo.image },
|
|
217
189
|
};
|
|
218
190
|
}
|
|
219
191
|
|
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,
|
|
@@ -93,11 +94,11 @@ export type ApiSite = {
|
|
|
93
94
|
canonicalUrl?: string;
|
|
94
95
|
logo?: string;
|
|
95
96
|
favicon?: string;
|
|
96
|
-
/** Round maker's stamp / seal —
|
|
97
|
+
/** Round maker's stamp / seal — `site_metadata_json.brand.seal`, when it is a URL. */
|
|
97
98
|
seal?: string;
|
|
98
99
|
/**
|
|
99
|
-
* Brand-asset registry: every `site_metadata_json.brand.<name>`
|
|
100
|
-
*
|
|
100
|
+
* Brand-asset registry: every `site_metadata_json.brand.<name>` that is a URL, path or data URI,
|
|
101
|
+
* used as-is. Bare asset slugs are left out (Marvin no longer serves assets by slug).
|
|
101
102
|
*/
|
|
102
103
|
brand?: Record<string, string>;
|
|
103
104
|
locale: string;
|
|
@@ -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';
|