@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/README.md CHANGED
@@ -258,6 +258,8 @@ this needs a newer SDK.
258
258
  |---|---|
259
259
  | `FormRenderer` | a public form from a submittable entry type's `formSchema`; unstyled, progressive-enhancement submit |
260
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)) |
261
263
  | `EntryRenderer` | picks the renderer for an entry from its entry type's `rendering` |
262
264
  | `PageRenderer`, `ArticleRenderer`, `FaqRenderer`, `NavigationRenderer` | unstyled, semantic core renderers with `data-renderer`/`data-role` hooks |
263
265
 
@@ -288,6 +290,116 @@ Options: `registry` (required), `apiUrl`, `siteToken` (default to the `MARVIN_*`
288
290
  Marvin's system entry types name their renderer package `@inneropen/marvin-renderers-core`; the
289
291
  core renderers still answer to that name (and to `@inneropen/marvin-astro`).
290
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
+
291
403
  ## Migrating from @inneropen/marvin-renderers-core
292
404
 
293
405
  The components and logic moved here with their names unchanged. Swap the import paths, then
@@ -354,7 +466,7 @@ verification tags. No styling, no site coupling.
354
466
  | `@inneropen/marvin-astro` | `createMarvinContent` and every helper below it |
355
467
  | `@inneropen/marvin-astro/types` | resolved types only (`ApiSite`, `ApiSeo`, `ApiSiteChrome`, …) |
356
468
  | `@inneropen/marvin-astro/astro` | `SeoHead` |
357
- | `@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` |
358
470
  | `@inneropen/marvin-astro/components/registry` | `getRenderer`, `astroRegistry`, `coreRendererPackage` |
359
471
  | `@inneropen/marvin-astro/components/integration` | `marvinIntegration` (for `astro.config.mjs`) |
360
472
  | `@inneropen/marvin-astro/components/*` | individual components, `renderers/*.astro`, `embeds.css` |
@@ -362,7 +474,7 @@ verification tags. No styling, no site coupling.
362
474
 
363
475
  Beyond `createMarvinContent`, the pieces are usable on their own: `createBackend`,
364
476
  `createFetcher`, `createRepository`, `createSiteLoader`, `createChromeLoader`,
365
- `createFieldAccessor`, `createMarkdownRenderer`, `entryEmbeds`, `findEmbed`, `siteEmbeds`,
477
+ `createFieldAccessor`, `createMarkdownRenderer`, `entryEmbeds`, `findEmbed`, `siteEmbeds`, `sitePrivacy`, `consentVersion`,
366
478
  `formatDisplayDate`, `selectValuesForPage`, and the whole `normalize` surface.
367
479
 
368
480
  ## Development
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ 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
5
  import { MarvinClient, MarvinEntry, Entry, PublishedCollectionSummary, Collection, CollectionEntry, MarvinSite, Workspace } 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';
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}. */
package/dist/index.js CHANGED
@@ -602,9 +602,9 @@ var EMBEDS = /* @__PURE__ */ Symbol("marvin.embeds");
602
602
  var BARE_URL = /^https?:\/\/\S+$/i;
603
603
  function paragraphEmbedHtml(token, embeds) {
604
604
  if (!embeds) return void 0;
605
- const text = token.text.trim();
606
- if (!BARE_URL.test(text)) return void 0;
607
- 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;
608
608
  return typeof html === "string" && html.trim() ? html : void 0;
609
609
  }
610
610
  function createMarkdownRenderer(options = {}) {
@@ -984,6 +984,109 @@ async function loadSectionLanding(fetcher, slug, renderMarkdown = createMarkdown
984
984
  };
985
985
  }
986
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
+
987
1090
  // src/site.ts
988
1091
  function buildSeo(raw, identity) {
989
1092
  const seo = raw ?? {};
@@ -1071,6 +1174,11 @@ function transformMarvinSite(marvinSite, fallback) {
1071
1174
  embeds: siteEmbeds(
1072
1175
  config.embeds ?? marvinSite.embeds
1073
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
+ ),
1074
1182
  metadata
1075
1183
  };
1076
1184
  }
@@ -1161,6 +1269,8 @@ function createMarvinContent(options = {}) {
1161
1269
  };
1162
1270
  }
1163
1271
  export {
1272
+ CONSENT_CATEGORIES,
1273
+ COOKIE_CATEGORIES,
1164
1274
  DEFAULT_DEV_RETRY_MS,
1165
1275
  DEFAULT_HYDRATE_CONCURRENCY,
1166
1276
  ENV_KEYS,
@@ -1175,6 +1285,9 @@ export {
1175
1285
  buildSeo,
1176
1286
  collectionRole,
1177
1287
  collectionSlugs,
1288
+ consentCategories,
1289
+ consentVersion,
1290
+ cookiesNeedConsent,
1178
1291
  createBackend,
1179
1292
  createChromeLoader,
1180
1293
  createFetcher,
@@ -1213,6 +1326,7 @@ export {
1213
1326
  selectImageAsset,
1214
1327
  selectValuesForPage,
1215
1328
  siteEmbeds,
1329
+ sitePrivacy,
1216
1330
  socialLinkFromKey,
1217
1331
  toNavigationLink
1218
1332
  };