@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/README.md
CHANGED
|
@@ -132,13 +132,15 @@ entry's image assets, then the list item's `featuredAsset`.
|
|
|
132
132
|
## Site and chrome
|
|
133
133
|
|
|
134
134
|
```ts
|
|
135
|
-
const site = await marvin.getSite(); // identity, SEO,
|
|
135
|
+
const site = await marvin.getSite(); // identity, SEO, logo/favicon URLs — memoized
|
|
136
136
|
const chrome = await marvin.getSiteChrome(); // nav, footer, legal, social, inquiry — memoized
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
`getSite()`
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
`getSite()` takes `logo`, `favicon` and `seo.image` as the URLs Marvin now returns (no asset
|
|
140
|
+
lookups). `site.brand.<name>` collects the `site_metadata_json.brand.<name>` values that are
|
|
141
|
+
URLs, root-relative paths or data URIs, used as-is; bare asset slugs are left out because the
|
|
142
|
+
publishing API no longer serves assets by slug. `brand.logo`, `brand.favicon` and `brand.seal`
|
|
143
|
+
win over the top-level `logo`/`favicon`/`seal`.
|
|
142
144
|
|
|
143
145
|
`getSiteChrome()` reads `main-navigation` and `footer-navigation` collections, splits
|
|
144
146
|
`role: 'legal'` entries into `legalLinks`, and groups the rest into footer columns. Social links
|
|
@@ -256,6 +258,8 @@ this needs a newer SDK.
|
|
|
256
258
|
|---|---|
|
|
257
259
|
| `FormRenderer` | a public form from a submittable entry type's `formSchema`; unstyled, progressive-enhancement submit |
|
|
258
260
|
| `Embed`, `LinkCard`, `EmbedLoader` | media embeds (above) |
|
|
261
|
+
| `PrivacyPolicy`, `CookieTable`, `ProcessorTable` | privacy & cookie policy rendering ([below](#privacy--cookies)) |
|
|
262
|
+
| `ConsentBanner`, `CookieSettingsLink` | cookie-consent banner and settings link ([below](#consent-banner)) |
|
|
259
263
|
| `EntryRenderer` | picks the renderer for an entry from its entry type's `rendering` |
|
|
260
264
|
| `PageRenderer`, `ArticleRenderer`, `FaqRenderer`, `NavigationRenderer` | unstyled, semantic core renderers with `data-renderer`/`data-role` hooks |
|
|
261
265
|
|
|
@@ -286,6 +290,116 @@ Options: `registry` (required), `apiUrl`, `siteToken` (default to the `MARVIN_*`
|
|
|
286
290
|
Marvin's system entry types name their renderer package `@inneropen/marvin-renderers-core`; the
|
|
287
291
|
core renderers still answer to that name (and to `@inneropen/marvin-astro`).
|
|
288
292
|
|
|
293
|
+
## Privacy & cookies
|
|
294
|
+
|
|
295
|
+
Marvin keeps the facts a privacy/cookie policy needs (`site.privacy`: the controller, processors and
|
|
296
|
+
the cookies/storage the site sets). `site.privacy` is read tolerantly (typed field or
|
|
297
|
+
`metadata.privacy`, camelCase or snake_case), so it needs no SDK bump; it is `undefined` until a
|
|
298
|
+
workspace configures it. Not legal advice: you decide the text and the classification.
|
|
299
|
+
|
|
300
|
+
```astro
|
|
301
|
+
---
|
|
302
|
+
import { PrivacyPolicy } from '@inneropen/marvin-astro/components';
|
|
303
|
+
const site = await marvin.getSite();
|
|
304
|
+
const entry = await policies.bySlug('privacy'); // your policy entry (prose)
|
|
305
|
+
---
|
|
306
|
+
<PrivacyPolicy privacy={site.privacy} headingLevel={2}>
|
|
307
|
+
<Fragment set:html={entry.bodyHtml} />
|
|
308
|
+
</PrivacyPolicy>
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
| Component | Renders |
|
|
312
|
+
|---|---|
|
|
313
|
+
| `PrivacyPolicy` | the entry prose (default slot), controller block, processors, cookies, "Last reviewed" |
|
|
314
|
+
| `CookieTable` | `cookies`, one table per category (`category` narrows it); adds the `marvin-consent` storage row as necessary when a banner is required (`includeConsentEntry={false}` to skip) |
|
|
315
|
+
| `ProcessorTable` | `processors`: region, transfer basis, DPA/privacy links (http(s) only) |
|
|
316
|
+
|
|
317
|
+
All server-rendered with zero JavaScript. Styling is `privacy.css`: zero-specificity `:where()` rules
|
|
318
|
+
you override freely, themed with `--marvin-privacy-gap | -border | -head-bg | -muted | -pad`; tables
|
|
319
|
+
stack below 600px. Every string has a neutral English default; pass `labels={{ ... }}` to change it.
|
|
320
|
+
|
|
321
|
+
`sitePrivacy(raw)` and `consentVersion(privacy)` are exported from the package root. The version is
|
|
322
|
+
a hash of the sorted non-necessary `category|name|provider` triples, so rewording a purpose or
|
|
323
|
+
moving `lastReviewed` never re-asks, while adding, removing or re-categorising a cookie does.
|
|
324
|
+
|
|
325
|
+
### Consent banner
|
|
326
|
+
|
|
327
|
+
`<ConsentBanner>` asks only when it has to. It renders **nothing, and ships no script or CSS**, unless
|
|
328
|
+
`site.privacy.bannerRequired` (at least one listed cookie/storage entry is not `necessary`), so a site
|
|
329
|
+
with no optional cookies carries zero banner bytes.
|
|
330
|
+
|
|
331
|
+
```astro
|
|
332
|
+
---
|
|
333
|
+
// src/layouts/BaseLayout.astro
|
|
334
|
+
import { ConsentBanner, CookieSettingsLink } from '@inneropen/marvin-astro/components';
|
|
335
|
+
const site = await marvin.getSite();
|
|
336
|
+
---
|
|
337
|
+
<html>
|
|
338
|
+
<body>
|
|
339
|
+
<ConsentBanner privacy={site.privacy} policyHref="/privacy" />
|
|
340
|
+
<main>...</main>
|
|
341
|
+
<footer>
|
|
342
|
+
<CookieSettingsLink href="/privacy" privacy={site.privacy} />
|
|
343
|
+
</footer>
|
|
344
|
+
</body>
|
|
345
|
+
</html>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
- **Fail closed.** The markup is `hidden` and the runtime reveals it only when the visitor has no valid
|
|
349
|
+
choice; without JavaScript there is no dead banner and nothing optional loads. The link in
|
|
350
|
+
`<CookieSettingsLink>` is a real link to your policy page; with JavaScript it opens the dialog.
|
|
351
|
+
- **Equal choices.** Accept all and Reject all share one class and size; Customise opens a native
|
|
352
|
+
`<dialog>` with one unticked checkbox per category listed in `site.privacy.cookies`. A non-modal bottom
|
|
353
|
+
bar, no focus stealing; after a choice focus moves to `<main>`, and the dialog returns it to its opener.
|
|
354
|
+
- **Remembered for 6 months**, refusals too (`maxAgeDays`, default 180), in
|
|
355
|
+
`localStorage['marvin-consent']`. The choice is bound to `consentVersion(privacy)`: adding, removing or
|
|
356
|
+
re-categorising an optional cookie asks everyone again; rewording does not. `consent.version` in
|
|
357
|
+
Marvin's privacy block is a manual re-ask bump. Safari caps script-written storage at 7 days without
|
|
358
|
+
interaction, so some visitors are asked sooner.
|
|
359
|
+
- **Disclosure.** `marvin-consent` is listed automatically by `<CookieTable>` / `<PrivacyPolicy>`.
|
|
360
|
+
|
|
361
|
+
Gate optional scripts and markup so they ship inert and run only after consent:
|
|
362
|
+
|
|
363
|
+
```html
|
|
364
|
+
<script type="text/plain" data-consent="analytics" data-src="https://stats.example/script.js" defer></script>
|
|
365
|
+
<script type="text/plain" data-consent="analytics">window.track?.();</script>
|
|
366
|
+
<template data-consent="marketing"><iframe src="https://ads.example/widget"></iframe></template>
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`data-consent` takes one or more categories (`preferences`, `analytics`, `marketing`; all must be
|
|
370
|
+
allowed). Other attributes on the script are copied over, `data-type="module"` restores a module type.
|
|
371
|
+
An unknown category never runs. A site whose `bannerRequired` is false never activates anything, so a
|
|
372
|
+
gated script with no matching entry in Marvin stays inert; a dev build logs a console warning for that.
|
|
373
|
+
Prefer `data-src` over inline code if you use a CSP (inline gated code needs a hash or `unsafe-inline`).
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
window.marvinConsent.get(); // { v, version, choices, at } | null while undecided
|
|
377
|
+
window.marvinConsent.has('analytics'); // 'necessary' is always true
|
|
378
|
+
window.marvinConsent.set({ analytics: true });
|
|
379
|
+
window.marvinConsent.open(); // the Customise dialog
|
|
380
|
+
document.addEventListener('marvin:consent', (e) => e.detail); // { choices, version, initial }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`marvin:consent` fires on load when a valid choice exists (`initial: true`, after the page's module
|
|
384
|
+
scripts have run, so late listeners see it) and on every change. Withdrawing a category deletes the
|
|
385
|
+
site's listed first-party cookies for it (a trailing `*` is a prefix: `_ga*`; the host and its parent
|
|
386
|
+
domains) and reloads the page so nothing keeps running.
|
|
387
|
+
|
|
388
|
+
**Embeds** stay click-to-load: the click on a facade is the visitor's consent, and they do not make
|
|
389
|
+
`bannerRequired` true. Opt in to auto-loading them after a category is allowed with
|
|
390
|
+
`<ConsentBanner embeds="marketing" ... />` (off by default; needs `<EmbedLoader />`).
|
|
391
|
+
|
|
392
|
+
Text defaults to neutral English: override per site with `labels={{ title, body, acceptAll, rejectAll,
|
|
393
|
+
customise, save, settingsLink, policyLink, dialogTitle, dialogBody, categories, categoryDescriptions }}`.
|
|
394
|
+
Theme with `--marvin-consent-bg | -fg | -accent | -radius | -font | -shadow | -z` (zero-specificity
|
|
395
|
+
`consent.css`; dark mode follows `prefers-color-scheme`). No consent log is kept anywhere.
|
|
396
|
+
|
|
397
|
+
`consent.css` is inlined as a `<style>` next to the banner markup, and the runtime `<script>` is only
|
|
398
|
+
emitted with it, so a page without a banner carries neither (Astro 5 ships a component's `<script>` only
|
|
399
|
+
where it renders but ships imported stylesheets wherever the component is merely imported, hence the
|
|
400
|
+
inline style). Under a CSP without `style-src 'unsafe-inline'`, pass `inlineStyles={false}` and import
|
|
401
|
+
`@inneropen/marvin-astro/components/consent.css` yourself. Check your build output on other Astro versions.
|
|
402
|
+
|
|
289
403
|
## Migrating from @inneropen/marvin-renderers-core
|
|
290
404
|
|
|
291
405
|
The components and logic moved here with their names unchanged. Swap the import paths, then
|
|
@@ -352,7 +466,7 @@ verification tags. No styling, no site coupling.
|
|
|
352
466
|
| `@inneropen/marvin-astro` | `createMarvinContent` and every helper below it |
|
|
353
467
|
| `@inneropen/marvin-astro/types` | resolved types only (`ApiSite`, `ApiSeo`, `ApiSiteChrome`, …) |
|
|
354
468
|
| `@inneropen/marvin-astro/astro` | `SeoHead` |
|
|
355
|
-
| `@inneropen/marvin-astro/components` | `FormRenderer`, `Embed`, `LinkCard`, `EmbedLoader`, `EntryRenderer`, the core renderers, registry, `marvinIntegration` |
|
|
469
|
+
| `@inneropen/marvin-astro/components` | `FormRenderer`, `Embed`, `LinkCard`, `EmbedLoader`, `PrivacyPolicy`, `CookieTable`, `ProcessorTable`, `ConsentBanner`, `CookieSettingsLink`, `EntryRenderer`, the core renderers, registry, `marvinIntegration` |
|
|
356
470
|
| `@inneropen/marvin-astro/components/registry` | `getRenderer`, `astroRegistry`, `coreRendererPackage` |
|
|
357
471
|
| `@inneropen/marvin-astro/components/integration` | `marvinIntegration` (for `astro.config.mjs`) |
|
|
358
472
|
| `@inneropen/marvin-astro/components/*` | individual components, `renderers/*.astro`, `embeds.css` |
|
|
@@ -360,7 +474,7 @@ verification tags. No styling, no site coupling.
|
|
|
360
474
|
|
|
361
475
|
Beyond `createMarvinContent`, the pieces are usable on their own: `createBackend`,
|
|
362
476
|
`createFetcher`, `createRepository`, `createSiteLoader`, `createChromeLoader`,
|
|
363
|
-
`createFieldAccessor`, `createMarkdownRenderer`, `entryEmbeds`, `findEmbed`, `siteEmbeds`,
|
|
477
|
+
`createFieldAccessor`, `createMarkdownRenderer`, `entryEmbeds`, `findEmbed`, `siteEmbeds`, `sitePrivacy`, `consentVersion`,
|
|
364
478
|
`formatDisplayDate`, `selectValuesForPage`, and the whole `normalize` surface.
|
|
365
479
|
|
|
366
480
|
## Development
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { MarkedOptions } from 'marked';
|
|
|
2
2
|
import { M as MarvinEmbeds, a as MarvinEmbed } from './embeds-BTNryQA6.js';
|
|
3
3
|
export { b as MarvinEmbedIframe, c as MarvinEmbedKind, d as MarvinEmbedLink, e as MarvinEmbedMode, f as MarvinEmbedStatus, g as MarvinSiteEmbeds, h as entryEmbeds, i as findEmbed, s as siteEmbeds } from './embeds-BTNryQA6.js';
|
|
4
4
|
import * as _inneropen_marvin_sdk from '@inneropen/marvin-sdk';
|
|
5
|
-
import { MarvinClient, MarvinEntry, Entry, PublishedCollectionSummary, Collection, CollectionEntry, MarvinSite, Workspace
|
|
6
|
-
import { MarvinContentEntry, ApiSite, ApiSeo, ApiSiteChrome, ApiNavigationLink, ApiSocialLink, AssetSelectOptions, ApiImage, ApiResourceLink, SectionLanding } from './types.js';
|
|
7
|
-
export { ApiActionVariant, ApiHero, ApiHeroAction, ApiPageAction } from './types.js';
|
|
5
|
+
import { MarvinClient, MarvinEntry, Entry, PublishedCollectionSummary, Collection, CollectionEntry, MarvinSite, Workspace } from '@inneropen/marvin-sdk';
|
|
6
|
+
import { M as MarvinContentEntry, A as ApiSite, a as ApiSeo, b as ApiSiteChrome, c as ApiNavigationLink, d as ApiSocialLink, e as AssetSelectOptions, f as ApiImage, g as ApiResourceLink, S as SectionLanding } from './types-4VdGN9e_.js';
|
|
7
|
+
export { h as ApiActionVariant, i as ApiHero, j as ApiHeroAction, k as ApiPageAction, C as CONSENT_CATEGORIES, l as COOKIE_CATEGORIES, m as ConsentCategory, n as CookieCategory, P as PrivacyConsent, o as PrivacyController, p as PrivacyCookie, q as PrivacyProcessor, r as SitePrivacy, s as consentCategories, t as consentVersion, u as cookiesNeedConsent, v as sitePrivacy } from './types-4VdGN9e_.js';
|
|
8
8
|
|
|
9
9
|
type MarkdownOptions = MarkedOptions;
|
|
10
10
|
/** Per-call options for a {@link MarkdownRenderer}. */
|
|
@@ -179,9 +179,6 @@ declare function createFetcher(backend: MarvinBackend): {
|
|
|
179
179
|
entry: (slug: string) => Promise<Entry | null>;
|
|
180
180
|
site(): Promise<MarvinSite | null>;
|
|
181
181
|
workspace(): Promise<Workspace | null>;
|
|
182
|
-
assets(type?: string): Promise<MarvinAsset[]>;
|
|
183
|
-
asset(slugOrId: string): Promise<MarvinAsset | null>;
|
|
184
|
-
resources(resourceType?: string): Promise<MarvinResource[]>;
|
|
185
182
|
};
|
|
186
183
|
|
|
187
184
|
/**
|
|
@@ -189,8 +186,8 @@ declare function createFetcher(backend: MarvinBackend): {
|
|
|
189
186
|
*
|
|
190
187
|
* Marvin's site configuration is optional almost everywhere; a template that had to guard every
|
|
191
188
|
* field would be unreadable. This resolves it once into an `ApiSite` where title, description,
|
|
192
|
-
* siteName, locale and timezone are always present, and
|
|
193
|
-
*
|
|
189
|
+
* siteName, locale and timezone are always present. Logo, favicon and SEO image are the URLs
|
|
190
|
+
* Marvin returns, used as-is.
|
|
194
191
|
*/
|
|
195
192
|
|
|
196
193
|
type SiteOptions = {
|
|
@@ -620,14 +617,11 @@ declare function createMarvinContent(options?: MarvinContentOptions): {
|
|
|
620
617
|
entry: (slug: string) => Promise<_inneropen_marvin_sdk.Entry | null>;
|
|
621
618
|
site(): Promise<_inneropen_marvin_sdk.MarvinSite | null>;
|
|
622
619
|
workspace(): Promise<_inneropen_marvin_sdk.Workspace | null>;
|
|
623
|
-
assets(type?: string): Promise<_inneropen_marvin_sdk.MarvinAsset[]>;
|
|
624
|
-
asset(slugOrId: string): Promise<_inneropen_marvin_sdk.MarvinAsset | null>;
|
|
625
|
-
resources(resourceType?: string): Promise<_inneropen_marvin_sdk.MarvinResource[]>;
|
|
626
620
|
};
|
|
627
621
|
renderMarkdown: MarkdownRenderer;
|
|
628
622
|
/** True when Marvin is configured and not currently latched out as unreachable. */
|
|
629
623
|
hasBackend: () => boolean;
|
|
630
|
-
/**
|
|
624
|
+
/** Site identity, SEO and logo/favicon URLs. Memoized per process. */
|
|
631
625
|
getSite(): Promise<ApiSite>;
|
|
632
626
|
/** Navigation, footer, legal, social and inquiry links. Memoized per process. */
|
|
633
627
|
getSiteChrome(): Promise<ApiSiteChrome>;
|
package/dist/index.js
CHANGED
|
@@ -546,15 +546,6 @@ function createFetcher(backend) {
|
|
|
546
546
|
},
|
|
547
547
|
workspace() {
|
|
548
548
|
return guarded("Workspace", null, () => backend.client().getWorkspace());
|
|
549
|
-
},
|
|
550
|
-
assets(type) {
|
|
551
|
-
return guarded("Assets", [], () => backend.client().assets.list({ type }));
|
|
552
|
-
},
|
|
553
|
-
asset(slugOrId) {
|
|
554
|
-
return guarded(`Asset "${slugOrId}"`, null, () => backend.client().assets.get(slugOrId));
|
|
555
|
-
},
|
|
556
|
-
resources(resourceType) {
|
|
557
|
-
return guarded("Resources", [], () => backend.client().resources.list({ resourceType }));
|
|
558
549
|
}
|
|
559
550
|
};
|
|
560
551
|
}
|
|
@@ -611,9 +602,9 @@ var EMBEDS = /* @__PURE__ */ Symbol("marvin.embeds");
|
|
|
611
602
|
var BARE_URL = /^https?:\/\/\S+$/i;
|
|
612
603
|
function paragraphEmbedHtml(token, embeds) {
|
|
613
604
|
if (!embeds) return void 0;
|
|
614
|
-
const
|
|
615
|
-
if (!BARE_URL.test(
|
|
616
|
-
const html = findEmbed(embeds,
|
|
605
|
+
const text2 = token.text.trim();
|
|
606
|
+
if (!BARE_URL.test(text2)) return void 0;
|
|
607
|
+
const html = findEmbed(embeds, text2)?.html;
|
|
617
608
|
return typeof html === "string" && html.trim() ? html : void 0;
|
|
618
609
|
}
|
|
619
610
|
function createMarkdownRenderer(options = {}) {
|
|
@@ -993,6 +984,109 @@ async function loadSectionLanding(fetcher, slug, renderMarkdown = createMarkdown
|
|
|
993
984
|
};
|
|
994
985
|
}
|
|
995
986
|
|
|
987
|
+
// src/privacy.ts
|
|
988
|
+
var COOKIE_CATEGORIES = [
|
|
989
|
+
"necessary",
|
|
990
|
+
"preferences",
|
|
991
|
+
"analytics",
|
|
992
|
+
"marketing"
|
|
993
|
+
];
|
|
994
|
+
var CONSENT_CATEGORIES = [
|
|
995
|
+
"preferences",
|
|
996
|
+
"analytics",
|
|
997
|
+
"marketing"
|
|
998
|
+
];
|
|
999
|
+
function field2(record, camel) {
|
|
1000
|
+
if (record[camel] !== void 0) return record[camel];
|
|
1001
|
+
return record[camel.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)];
|
|
1002
|
+
}
|
|
1003
|
+
function text(record, camel) {
|
|
1004
|
+
const value = asString(field2(record, camel));
|
|
1005
|
+
return value?.trim();
|
|
1006
|
+
}
|
|
1007
|
+
function records(value) {
|
|
1008
|
+
return Array.isArray(value) ? value.filter((item) => !!item && typeof item === "object") : [];
|
|
1009
|
+
}
|
|
1010
|
+
function readController(raw) {
|
|
1011
|
+
const value = asRecord(raw);
|
|
1012
|
+
const legalName = text(value, "legalName");
|
|
1013
|
+
if (!legalName) return void 0;
|
|
1014
|
+
return {
|
|
1015
|
+
legalName,
|
|
1016
|
+
contactEmail: text(value, "contactEmail"),
|
|
1017
|
+
address: text(value, "address"),
|
|
1018
|
+
euRepresentative: text(value, "euRepresentative"),
|
|
1019
|
+
dpoEmail: text(value, "dpoEmail")
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
function readProcessor(value) {
|
|
1023
|
+
const name = text(value, "name");
|
|
1024
|
+
if (!name) return void 0;
|
|
1025
|
+
const categories = field2(value, "dataCategories");
|
|
1026
|
+
return {
|
|
1027
|
+
name,
|
|
1028
|
+
purpose: text(value, "purpose"),
|
|
1029
|
+
dataCategories: Array.isArray(categories) ? categories.filter((item) => typeof item === "string" && item.trim() !== "") : [],
|
|
1030
|
+
region: text(value, "region"),
|
|
1031
|
+
transferBasis: text(value, "transferBasis"),
|
|
1032
|
+
dpaUrl: text(value, "dpaUrl"),
|
|
1033
|
+
privacyUrl: text(value, "privacyUrl")
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
function readCookie(value) {
|
|
1037
|
+
const name = text(value, "name");
|
|
1038
|
+
if (!name) return void 0;
|
|
1039
|
+
const category = text(value, "category");
|
|
1040
|
+
return {
|
|
1041
|
+
name,
|
|
1042
|
+
provider: text(value, "provider") ?? "",
|
|
1043
|
+
purpose: text(value, "purpose") ?? "",
|
|
1044
|
+
duration: text(value, "duration"),
|
|
1045
|
+
// A category a newer server invented is treated as the strictest optional one, never as
|
|
1046
|
+
// `necessary`: it must be asked about before anything it covers loads.
|
|
1047
|
+
category: COOKIE_CATEGORIES.includes(category) ? category : "marketing"
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
function cookiesNeedConsent(cookies) {
|
|
1051
|
+
return cookies.some((cookie) => cookie.category !== "necessary");
|
|
1052
|
+
}
|
|
1053
|
+
function sitePrivacy(raw) {
|
|
1054
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
1055
|
+
const value = raw;
|
|
1056
|
+
const consent = asRecord(field2(value, "consent"));
|
|
1057
|
+
const cookies = records(value.cookies).map(readCookie).filter((cookie) => cookie !== void 0);
|
|
1058
|
+
const declared = field2(value, "bannerRequired");
|
|
1059
|
+
return {
|
|
1060
|
+
controller: readController(value.controller),
|
|
1061
|
+
processors: records(value.processors).map(readProcessor).filter((processor) => processor !== void 0),
|
|
1062
|
+
cookies,
|
|
1063
|
+
consent: {
|
|
1064
|
+
policyEntrySlug: text(consent, "policyEntrySlug"),
|
|
1065
|
+
cookiePolicyEntrySlug: text(consent, "cookiePolicyEntrySlug"),
|
|
1066
|
+
lastReviewed: text(consent, "lastReviewed"),
|
|
1067
|
+
version: text(consent, "version")
|
|
1068
|
+
},
|
|
1069
|
+
bannerRequired: typeof declared === "boolean" ? declared : cookiesNeedConsent(cookies)
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
function fnv1a(input) {
|
|
1073
|
+
let hash = 2166136261;
|
|
1074
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1075
|
+
hash ^= input.charCodeAt(index);
|
|
1076
|
+
hash = Math.imul(hash, 16777619);
|
|
1077
|
+
}
|
|
1078
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
1079
|
+
}
|
|
1080
|
+
function consentVersion(privacy) {
|
|
1081
|
+
const triples = (privacy?.cookies ?? []).filter((cookie) => cookie.category !== "necessary").map((cookie) => `${cookie.category}|${cookie.name}|${cookie.provider}`).sort();
|
|
1082
|
+
const bump = privacy?.consent.version;
|
|
1083
|
+
return fnv1a(bump ? [...triples, `v:${bump}`].join("\n") : triples.join("\n"));
|
|
1084
|
+
}
|
|
1085
|
+
function consentCategories(privacy) {
|
|
1086
|
+
const present = new Set((privacy?.cookies ?? []).map((cookie) => cookie.category));
|
|
1087
|
+
return CONSENT_CATEGORIES.filter((category) => present.has(category));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
996
1090
|
// src/site.ts
|
|
997
1091
|
function buildSeo(raw, identity) {
|
|
998
1092
|
const seo = raw ?? {};
|
|
@@ -1080,54 +1174,37 @@ function transformMarvinSite(marvinSite, fallback) {
|
|
|
1080
1174
|
embeds: siteEmbeds(
|
|
1081
1175
|
config.embeds ?? marvinSite.embeds
|
|
1082
1176
|
),
|
|
1177
|
+
// Typed `config.privacy` when the SDK declares it (SDK 7.2+), else the raw blob under
|
|
1178
|
+
// `metadata.privacy`; this package's SDK peer range predates the typed field, so read both.
|
|
1179
|
+
privacy: sitePrivacy(
|
|
1180
|
+
config.privacy ?? marvinSite.privacy ?? metadata.privacy
|
|
1181
|
+
),
|
|
1083
1182
|
metadata
|
|
1084
1183
|
};
|
|
1085
1184
|
}
|
|
1086
|
-
function
|
|
1087
|
-
return
|
|
1185
|
+
function isUrlLike(value) {
|
|
1186
|
+
return /^https?:\/\//.test(value) || value.startsWith("/") || value.startsWith("data:");
|
|
1088
1187
|
}
|
|
1089
1188
|
function createSiteLoader(fetcher, options = {}) {
|
|
1090
1189
|
const fallback = buildFallbackSite(options);
|
|
1091
1190
|
let promise = null;
|
|
1092
1191
|
let fromMarvin = false;
|
|
1093
|
-
async function resolveAssetSlug(slug) {
|
|
1094
|
-
if (!slug) return void 0;
|
|
1095
|
-
return assetUrl(await fetcher.asset(slug));
|
|
1096
|
-
}
|
|
1097
|
-
async function resolveMaybeSlug(value) {
|
|
1098
|
-
if (!value || !looksLikeAssetSlug(value)) return value;
|
|
1099
|
-
return await resolveAssetSlug(value) ?? value;
|
|
1100
|
-
}
|
|
1101
1192
|
async function load() {
|
|
1102
1193
|
const marvinSite = await fetcher.site();
|
|
1103
1194
|
fromMarvin = marvinSite !== null;
|
|
1104
1195
|
if (!marvinSite) return fallback;
|
|
1105
1196
|
const site = transformMarvinSite(marvinSite, fallback);
|
|
1106
|
-
const brandPairs = Object.entries(asRecord(site.metadata.brand)).filter(
|
|
1107
|
-
(entry) => typeof entry[1] === "string" && entry[1].trim().length > 0
|
|
1108
|
-
);
|
|
1109
|
-
const resolved = await Promise.all(
|
|
1110
|
-
brandPairs.map(async ([name, slug]) => [name, await resolveAssetSlug(slug)])
|
|
1111
|
-
);
|
|
1112
1197
|
const brand = {};
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
if (!url) continue;
|
|
1116
|
-
brand[name] = url;
|
|
1117
|
-
brandBySlug.set(brandPairs[index][1], url);
|
|
1198
|
+
for (const [name, value] of Object.entries(asRecord(site.metadata.brand))) {
|
|
1199
|
+
if (typeof value === "string" && isUrlLike(value.trim())) brand[name] = value.trim();
|
|
1118
1200
|
}
|
|
1119
|
-
const rawImage = site.seo.image;
|
|
1120
|
-
const ogImage = rawImage && looksLikeAssetSlug(rawImage) ? brandBySlug.get(rawImage) ?? await resolveAssetSlug(rawImage) : void 0;
|
|
1121
|
-
const logo = brand.logo ?? await resolveMaybeSlug(site.logo);
|
|
1122
|
-
const favicon = brand.favicon ?? await resolveMaybeSlug(site.favicon);
|
|
1123
1201
|
return {
|
|
1124
1202
|
...site,
|
|
1125
1203
|
brand,
|
|
1126
1204
|
// Convenience aliases for common callers; all of these also live in `site.brand`.
|
|
1127
|
-
logo,
|
|
1128
|
-
favicon,
|
|
1129
|
-
seal: brand.seal ?? site.seal
|
|
1130
|
-
seo: { ...site.seo, image: ogImage ?? site.seo.image }
|
|
1205
|
+
logo: brand.logo ?? site.logo,
|
|
1206
|
+
favicon: brand.favicon ?? site.favicon,
|
|
1207
|
+
seal: brand.seal ?? site.seal
|
|
1131
1208
|
};
|
|
1132
1209
|
}
|
|
1133
1210
|
return {
|
|
@@ -1164,7 +1241,7 @@ function createMarvinContent(options = {}) {
|
|
|
1164
1241
|
renderMarkdown,
|
|
1165
1242
|
/** True when Marvin is configured and not currently latched out as unreachable. */
|
|
1166
1243
|
hasBackend: () => backend.hasBackend(),
|
|
1167
|
-
/**
|
|
1244
|
+
/** Site identity, SEO and logo/favicon URLs. Memoized per process. */
|
|
1168
1245
|
getSite() {
|
|
1169
1246
|
return siteLoader.get();
|
|
1170
1247
|
},
|
|
@@ -1192,6 +1269,8 @@ function createMarvinContent(options = {}) {
|
|
|
1192
1269
|
};
|
|
1193
1270
|
}
|
|
1194
1271
|
export {
|
|
1272
|
+
CONSENT_CATEGORIES,
|
|
1273
|
+
COOKIE_CATEGORIES,
|
|
1195
1274
|
DEFAULT_DEV_RETRY_MS,
|
|
1196
1275
|
DEFAULT_HYDRATE_CONCURRENCY,
|
|
1197
1276
|
ENV_KEYS,
|
|
@@ -1206,6 +1285,9 @@ export {
|
|
|
1206
1285
|
buildSeo,
|
|
1207
1286
|
collectionRole,
|
|
1208
1287
|
collectionSlugs,
|
|
1288
|
+
consentCategories,
|
|
1289
|
+
consentVersion,
|
|
1290
|
+
cookiesNeedConsent,
|
|
1209
1291
|
createBackend,
|
|
1210
1292
|
createChromeLoader,
|
|
1211
1293
|
createFetcher,
|
|
@@ -1244,6 +1326,7 @@ export {
|
|
|
1244
1326
|
selectImageAsset,
|
|
1245
1327
|
selectValuesForPage,
|
|
1246
1328
|
siteEmbeds,
|
|
1329
|
+
sitePrivacy,
|
|
1247
1330
|
socialLinkFromKey,
|
|
1248
1331
|
toNavigationLink
|
|
1249
1332
|
};
|