@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/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 where brand asset slugs have already
7
- * become URLs.
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, assetUrl } from './normalize.js';
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
- function looksLikeAssetSlug(value: string): boolean {
139
- return !/^https?:\/\//.test(value) && !value.startsWith('/') && !value.startsWith('data:');
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: resolve EVERY `brand.<name>` slug → URL in one pass. A site adds a
178
- // shared asset with one config line (`brand.paperFold = "paper-fold"`) plus an upload;
179
- // components then read `site.brand.paperFold`. No code change per asset.
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 brandBySlug = new Map<string, string>();
189
- for (const [index, [name, url]] of resolved.entries()) {
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 — resolved from `site_metadata_json.brand.seal` (asset slug). */
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>` slug resolved to its URL.
100
- * Add a shared asset with one config line; components read `site.brand.<name>`.
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';