@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 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, brand assets — memoized
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()` resolves every `site_metadata_json.brand.<name>` asset slug to a URL in one pass, so
140
- a site adds a shared brand asset with one config line and reads `site.brand.<name>` — no code
141
- change per asset. `logo`, `favicon` and `seal` are aliased onto the top level.
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, MarvinAsset, MarvinResource } from '@inneropen/marvin-sdk';
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 where brand asset slugs have already
193
- * become URLs.
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
- /** Resolved site identity, SEO and brand assets. Memoized per process. */
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 text = token.text.trim();
615
- if (!BARE_URL.test(text)) return void 0;
616
- const html = findEmbed(embeds, text)?.html;
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 looksLikeAssetSlug(value) {
1087
- return !/^https?:\/\//.test(value) && !value.startsWith("/") && !value.startsWith("data:");
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 brandBySlug = /* @__PURE__ */ new Map();
1114
- for (const [index, [name, url]] of resolved.entries()) {
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
- /** Resolved site identity, SEO and brand assets. Memoized per process. */
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
  };