@inneropen/marvin-astro 2.0.0 → 2.1.1

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.
@@ -0,0 +1,99 @@
1
+ ---
2
+ /**
3
+ * CookieTable — the cookies and storage entries a site sets, one table per category.
4
+ *
5
+ * Server-rendered, no JavaScript. Pass `site.privacy.cookies` (or a pre-filtered list); `category`
6
+ * narrows it to one table. When any listed entry needs consent (the consent banner is on), the
7
+ * `marvin-consent` entry the banner itself stores is added as a necessary row unless it is
8
+ * already listed or `includeConsentEntry={false}`, and a <noscript> notice explains why optional
9
+ * cookies are off without JavaScript.
10
+ *
11
+ * Styles come from `privacy.css`: zero specificity, themeable with `--marvin-privacy-*`.
12
+ */
13
+ import type { CookieCategory, PrivacyCookie } from '../privacy.js';
14
+ import { COOKIE_CATEGORIES, cookiesNeedConsent } from '../privacy.js';
15
+ import {
16
+ CATEGORY_DESCRIPTIONS,
17
+ CONSENT_STORAGE_KEY,
18
+ DEFAULT_MAX_AGE_DAYS,
19
+ formatMaxAge,
20
+ privacyLabels,
21
+ type PrivacyLabels,
22
+ } from './privacy-text.js';
23
+ import './privacy.css';
24
+
25
+ interface Props {
26
+ cookies: PrivacyCookie[] | null | undefined;
27
+ /** Render only this category. */
28
+ category?: CookieCategory;
29
+ /** Add the `marvin-consent` row when the banner is required. Default true. */
30
+ includeConsentEntry?: boolean;
31
+ /** Must match `<ConsentBanner maxAgeDays>`; shown as the consent entry's duration. */
32
+ maxAgeDays?: number;
33
+ labels?: Partial<Omit<PrivacyLabels, 'categories'>> & {
34
+ categories?: Partial<Record<CookieCategory, string>>;
35
+ };
36
+ class?: string;
37
+ }
38
+
39
+ const {
40
+ cookies: rawCookies,
41
+ category,
42
+ includeConsentEntry = true,
43
+ maxAgeDays = DEFAULT_MAX_AGE_DAYS,
44
+ class: className,
45
+ } = Astro.props;
46
+ const labels = privacyLabels(Astro.props.labels);
47
+ const listed = rawCookies ?? [];
48
+ const bannerRequired = cookiesNeedConsent(listed);
49
+
50
+ const all: PrivacyCookie[] =
51
+ bannerRequired && includeConsentEntry && !listed.some((cookie) => cookie.name === CONSENT_STORAGE_KEY)
52
+ ? [
53
+ ...listed,
54
+ {
55
+ name: CONSENT_STORAGE_KEY,
56
+ provider: labels.thisSite,
57
+ purpose: labels.consentCookiePurpose,
58
+ duration: formatMaxAge(maxAgeDays),
59
+ category: 'necessary',
60
+ },
61
+ ]
62
+ : listed;
63
+
64
+ const groups = COOKIE_CATEGORIES.filter((name) => !category || name === category)
65
+ .map((name) => ({ name, rows: all.filter((cookie) => cookie.category === name) }))
66
+ .filter((group) => group.rows.length > 0);
67
+ ---
68
+
69
+ {bannerRequired && (
70
+ <noscript><p class="marvin-privacy__notice">{labels.noscript}</p></noscript>
71
+ )}
72
+ {groups.map((group) => (
73
+ <div class="marvin-privacy__table-wrap">
74
+ <table class={['marvin-privacy__table', 'marvin-privacy__table--cookies', className].filter(Boolean).join(' ')} data-category={group.name}>
75
+ <caption>
76
+ {labels.categories[group.name]}
77
+ <small>{CATEGORY_DESCRIPTIONS[group.name]}</small>
78
+ </caption>
79
+ <thead>
80
+ <tr>
81
+ <th scope="col">{labels.cookieName}</th>
82
+ <th scope="col">{labels.provider}</th>
83
+ <th scope="col">{labels.purpose}</th>
84
+ <th scope="col">{labels.duration}</th>
85
+ </tr>
86
+ </thead>
87
+ <tbody>
88
+ {group.rows.map((cookie) => (
89
+ <tr>
90
+ <th scope="row"><code>{cookie.name}</code></th>
91
+ <td data-label={labels.provider}>{cookie.provider}</td>
92
+ <td data-label={labels.purpose}>{cookie.purpose}</td>
93
+ <td data-label={labels.duration}>{cookie.duration}</td>
94
+ </tr>
95
+ ))}
96
+ </tbody>
97
+ </table>
98
+ </div>
99
+ ))}
@@ -0,0 +1,118 @@
1
+ ---
2
+ /**
3
+ * PrivacyPolicy — the structured half of a privacy / cookie policy page.
4
+ *
5
+ * Renders, from `site.privacy`: the entry's own prose (the default slot, e.g. the policy entry's
6
+ * body HTML), then the controller block, the processors table, the cookies tables and the
7
+ * "Last reviewed" date. Server-rendered, zero JavaScript. Sections with nothing to say are left
8
+ * out. `headingLevel` is the level of the section headings (sub-sections never go past h6).
9
+ *
10
+ * ```astro
11
+ * <PrivacyPolicy privacy={site.privacy}>
12
+ * <Fragment set:html={entry.bodyHtml} />
13
+ * </PrivacyPolicy>
14
+ * ```
15
+ */
16
+ import { formatDisplayDate } from '../format.js';
17
+ import type { CookieCategory, SitePrivacy } from '../privacy.js';
18
+ import { cookiesNeedConsent } from '../privacy.js';
19
+ import CookieTable from './CookieTable.astro';
20
+ import ProcessorTable from './ProcessorTable.astro';
21
+ import { DEFAULT_MAX_AGE_DAYS, privacyLabels, type PrivacyLabels } from './privacy-text.js';
22
+ import './privacy.css';
23
+
24
+ interface Props {
25
+ privacy: SitePrivacy | null | undefined;
26
+ /** Level of the section headings, 1–6. Default 2. */
27
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
28
+ includeConsentEntry?: boolean;
29
+ maxAgeDays?: number;
30
+ labels?: Partial<Omit<PrivacyLabels, 'categories'>> & {
31
+ categories?: Partial<Record<CookieCategory, string>>;
32
+ };
33
+ class?: string;
34
+ }
35
+
36
+ const {
37
+ privacy,
38
+ headingLevel = 2,
39
+ includeConsentEntry = true,
40
+ maxAgeDays = DEFAULT_MAX_AGE_DAYS,
41
+ class: className,
42
+ } = Astro.props;
43
+ const labels = privacyLabels(Astro.props.labels);
44
+
45
+ const level = Math.min(6, Math.max(1, Math.trunc(headingLevel)));
46
+ const Heading = `h${level}` as 'h2';
47
+ const controller = privacy?.controller;
48
+ const mailto = (email: string | undefined) =>
49
+ email && /^[^\s@<>"]+@[^\s@<>"]+$/.test(email) ? `mailto:${email}` : undefined;
50
+ const contactHref = mailto(controller?.contactEmail);
51
+ const dpoHref = mailto(controller?.dpoEmail);
52
+ const lastReviewed = privacy?.consent.lastReviewed;
53
+ const hasCookies = (privacy?.cookies.length ?? 0) > 0;
54
+ const needsConsent = cookiesNeedConsent(privacy?.cookies ?? []);
55
+ const hasSlot = Astro.slots.has('default');
56
+ ---
57
+
58
+ {(privacy || hasSlot) && (
59
+ <div class={['marvin-privacy', className].filter(Boolean).join(' ')}>
60
+ {hasSlot && (
61
+ <div class="marvin-privacy__prose"><slot /></div>
62
+ )}
63
+
64
+ {controller && (
65
+ <section class="marvin-privacy__section" id="marvin-privacy-controller">
66
+ <Heading>{labels.controller}</Heading>
67
+ <dl class="marvin-privacy__facts">
68
+ <dt>{labels.legalName}</dt>
69
+ <dd>{controller.legalName}</dd>
70
+ {controller.address && (<><dt>{labels.address}</dt><dd>{controller.address}</dd></>)}
71
+ {controller.contactEmail && (
72
+ <>
73
+ <dt>{labels.contact}</dt>
74
+ <dd>{contactHref ? <a href={contactHref}>{controller.contactEmail}</a> : controller.contactEmail}</dd>
75
+ </>
76
+ )}
77
+ {controller.euRepresentative && (
78
+ <><dt>{labels.euRepresentative}</dt><dd>{controller.euRepresentative}</dd></>
79
+ )}
80
+ {controller.dpoEmail && (
81
+ <>
82
+ <dt>{labels.dpo}</dt>
83
+ <dd>{dpoHref ? <a href={dpoHref}>{controller.dpoEmail}</a> : controller.dpoEmail}</dd>
84
+ </>
85
+ )}
86
+ </dl>
87
+ </section>
88
+ )}
89
+
90
+ {privacy && privacy.processors.length > 0 && (
91
+ <section class="marvin-privacy__section" id="marvin-privacy-processors">
92
+ <Heading>{labels.processors}</Heading>
93
+ <ProcessorTable processors={privacy.processors} labels={Astro.props.labels} />
94
+ </section>
95
+ )}
96
+
97
+ {privacy && hasCookies && (
98
+ <section class="marvin-privacy__section" id="marvin-privacy-cookies">
99
+ <Heading>{labels.cookies}</Heading>
100
+ {needsConsent && (
101
+ <p><a href="#marvin-privacy-cookies" data-marvin-consent-open>{labels.changeSettings}</a></p>
102
+ )}
103
+ <CookieTable
104
+ cookies={privacy.cookies}
105
+ includeConsentEntry={includeConsentEntry}
106
+ maxAgeDays={maxAgeDays}
107
+ labels={Astro.props.labels}
108
+ />
109
+ </section>
110
+ )}
111
+
112
+ {lastReviewed && (
113
+ <p class="marvin-privacy__reviewed">
114
+ {labels.lastReviewed}: <time datetime={lastReviewed}>{formatDisplayDate(lastReviewed)}</time>
115
+ </p>
116
+ )}
117
+ </div>
118
+ )}
@@ -0,0 +1,64 @@
1
+ ---
2
+ /**
3
+ * ProcessorTable — third parties that handle visitor data on the site's behalf.
4
+ *
5
+ * Server-rendered, no JavaScript. Pass `site.privacy.processors`. Region and transfer basis show
6
+ * as Marvin stored them; DPA / privacy-policy links render only when they are http(s) URLs.
7
+ */
8
+ import type { CookieCategory, PrivacyProcessor } from '../privacy.js';
9
+ import { privacyLabels, type PrivacyLabels } from './privacy-text.js';
10
+ import { safeLinkHref } from './embed-frame.js';
11
+ import './privacy.css';
12
+
13
+ interface Props {
14
+ processors: PrivacyProcessor[] | null | undefined;
15
+ labels?: Partial<Omit<PrivacyLabels, 'categories'>> & {
16
+ categories?: Partial<Record<CookieCategory, string>>;
17
+ };
18
+ class?: string;
19
+ }
20
+
21
+ const { processors, class: className } = Astro.props;
22
+ const labels = privacyLabels(Astro.props.labels);
23
+ const rows = (processors ?? []).map((processor) => ({
24
+ processor,
25
+ dpa: safeLinkHref(processor.dpaUrl),
26
+ privacy: safeLinkHref(processor.privacyUrl),
27
+ }));
28
+ ---
29
+
30
+ {rows.length > 0 && (
31
+ <div class="marvin-privacy__table-wrap">
32
+ <table class={['marvin-privacy__table', 'marvin-privacy__table--processors', className].filter(Boolean).join(' ')}>
33
+ <thead>
34
+ <tr>
35
+ <th scope="col">{labels.processor}</th>
36
+ <th scope="col">{labels.purpose}</th>
37
+ <th scope="col">{labels.data}</th>
38
+ <th scope="col">{labels.region}</th>
39
+ <th scope="col">{labels.transferBasis}</th>
40
+ <th scope="col">{labels.links}</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ {rows.map(({ processor, dpa, privacy }) => (
45
+ <tr>
46
+ <th scope="row">{processor.name}</th>
47
+ <td data-label={labels.purpose}>{processor.purpose}</td>
48
+ <td data-label={labels.data}>{processor.dataCategories.join(', ')}</td>
49
+ <td data-label={labels.region}>{processor.region}</td>
50
+ <td data-label={labels.transferBasis}>{processor.transferBasis}</td>
51
+ <td data-label={labels.links}>
52
+ {(dpa || privacy) && (
53
+ <ul class="marvin-privacy__links">
54
+ {privacy && <li><a href={privacy} rel="noopener noreferrer">{labels.privacyPolicy}</a></li>}
55
+ {dpa && <li><a href={dpa} rel="noopener noreferrer">{labels.dpa}</a></li>}
56
+ </ul>
57
+ )}
58
+ </td>
59
+ </tr>
60
+ ))}
61
+ </tbody>
62
+ </table>
63
+ </div>
64
+ )}
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Base styles for <ConsentBanner>.
3
+ *
4
+ * Every selector is wrapped in :where() so it carries zero specificity: any site rule wins.
5
+ * Theme through the custom properties below rather than overriding the rules.
6
+ *
7
+ * --marvin-consent-bg bar and dialog background (default #fff; #1c1c1c in dark mode)
8
+ * --marvin-consent-fg text colour (default #1a1a1a; #f2f2f2 in dark mode)
9
+ * --marvin-consent-accent button fill and focus ring (default #1a1a1a; #f2f2f2 in dark mode)
10
+ * --marvin-consent-radius corner radius (default 0.5rem)
11
+ * --marvin-consent-font font (default inherit)
12
+ * --marvin-consent-shadow bar shadow (default 0 -4px 24px rgb(0 0 0 / 0.18))
13
+ * --marvin-consent-z bar z-index (default 1000)
14
+ *
15
+ * The buttons share one equal-width column each, so Accept all and Reject all are the same size.
16
+ * Button text uses the background colour, so any bg/accent pair that contrasts keeps the buttons
17
+ * readable. Accept all and Reject all share one rule, so they can never differ in prominence.
18
+ *
19
+ * Markup:
20
+ * div.marvin-consent
21
+ * section.marvin-consent__bar[hidden] (the runtime removes `hidden` when a choice is needed)
22
+ * div.marvin-consent__text p.marvin-consent__title p.marvin-consent__body
23
+ * div.marvin-consent__actions button.marvin-consent__button (x3)
24
+ * dialog.marvin-consent__dialog div.marvin-consent__category (x n) div.marvin-consent__actions
25
+ */
26
+
27
+ :where(.marvin-consent) {
28
+ --_bg: var(--marvin-consent-bg, #fff);
29
+ --_fg: var(--marvin-consent-fg, #1a1a1a);
30
+ --_accent: var(--marvin-consent-accent, #1a1a1a);
31
+ font-family: var(--marvin-consent-font, inherit);
32
+ font-size: 1rem;
33
+ line-height: 1.5;
34
+ }
35
+
36
+ @media (prefers-color-scheme: dark) {
37
+ :where(.marvin-consent) {
38
+ --_bg: var(--marvin-consent-bg, #1c1c1c);
39
+ --_fg: var(--marvin-consent-fg, #f2f2f2);
40
+ --_accent: var(--marvin-consent-accent, #f2f2f2);
41
+ }
42
+ }
43
+
44
+ :where(.marvin-consent__bar) {
45
+ position: fixed;
46
+ inset: auto 0 0 0;
47
+ z-index: var(--marvin-consent-z, 1000);
48
+ box-sizing: border-box;
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ gap: 1rem 2rem;
54
+ max-height: 40vh;
55
+ overflow-y: auto;
56
+ padding: 1rem max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
57
+ background: var(--_bg);
58
+ color: var(--_fg);
59
+ box-shadow: var(--marvin-consent-shadow, 0 -4px 24px rgb(0 0 0 / 0.18));
60
+ }
61
+
62
+ /* `display` above would beat the UA's [hidden] rule; this keeps `hidden` meaning hidden. */
63
+ :where(.marvin-consent__bar)[hidden] {
64
+ display: none;
65
+ }
66
+
67
+ /* The bar covers the foot of the page while it is up; keep focused elements clear of it. */
68
+ :where(html:has(.marvin-consent__bar:not([hidden]))) {
69
+ scroll-padding-bottom: 40vh;
70
+ }
71
+
72
+ :where(.marvin-consent__text) {
73
+ flex: 1 1 24rem;
74
+ max-width: 48rem;
75
+ }
76
+
77
+ :where(.marvin-consent__title) {
78
+ margin: 0 0 0.25rem;
79
+ font-size: 1.125rem;
80
+ font-weight: 700;
81
+ }
82
+
83
+ :where(.marvin-consent__body, .marvin-consent__description, .marvin-consent__cookies) {
84
+ margin: 0;
85
+ }
86
+
87
+ :where(.marvin-consent a) {
88
+ color: inherit;
89
+ text-decoration: underline;
90
+ }
91
+
92
+ :where(.marvin-consent__actions) {
93
+ display: grid;
94
+ grid-auto-flow: column;
95
+ grid-auto-columns: 1fr;
96
+ gap: 0.75rem;
97
+ }
98
+
99
+ :where(.marvin-consent__button) {
100
+ box-sizing: border-box;
101
+ min-height: 2.75rem;
102
+ min-width: 7rem;
103
+ padding: 0.5rem 1.25rem;
104
+ border: 2px solid var(--_accent);
105
+ border-radius: var(--marvin-consent-radius, 0.5rem);
106
+ background: var(--_accent);
107
+ color: var(--_bg);
108
+ font: inherit;
109
+ font-weight: 600;
110
+ cursor: pointer;
111
+ }
112
+
113
+ :where(.marvin-consent__button--customise) {
114
+ background: transparent;
115
+ color: var(--_fg);
116
+ }
117
+
118
+ :where(.marvin-consent__button:focus-visible, .marvin-consent input:focus-visible) {
119
+ outline: 3px solid var(--_accent);
120
+ outline-offset: 3px;
121
+ }
122
+
123
+ :where(.marvin-consent__dialog) {
124
+ box-sizing: border-box;
125
+ width: min(34rem, calc(100vw - 2rem));
126
+ max-height: calc(100vh - 2rem);
127
+ overflow-y: auto;
128
+ padding: 1.5rem;
129
+ border: 0;
130
+ border-radius: var(--marvin-consent-radius, 0.5rem);
131
+ background: var(--_bg);
132
+ color: var(--_fg);
133
+ box-shadow: 0 8px 40px rgb(0 0 0 / 0.35);
134
+ }
135
+
136
+ :where(.marvin-consent__dialog)::backdrop {
137
+ background: rgb(0 0 0 / 0.55);
138
+ }
139
+
140
+ :where(.marvin-consent__category) {
141
+ margin: 1rem 0;
142
+ padding-top: 1rem;
143
+ border-top: 1px solid color-mix(in srgb, var(--_fg) 25%, transparent);
144
+ }
145
+
146
+ :where(.marvin-consent__label) {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 0.625rem;
150
+ font-weight: 600;
151
+ }
152
+
153
+ :where(.marvin-consent__label input) {
154
+ width: 1.25rem;
155
+ height: 1.25rem;
156
+ margin: 0;
157
+ accent-color: var(--_accent);
158
+ }
159
+
160
+ :where(.marvin-consent__description, .marvin-consent__cookies) {
161
+ margin-top: 0.25rem;
162
+ padding-left: 1.875rem;
163
+ }
164
+
165
+ :where(.marvin-consent__dialog .marvin-consent__actions) {
166
+ margin-top: 1.25rem;
167
+ }
168
+
169
+ @media (prefers-reduced-motion: no-preference) {
170
+ :where(.marvin-consent__bar) {
171
+ animation: marvin-consent-in 0.25s ease-out;
172
+ }
173
+ }
174
+
175
+ @keyframes marvin-consent-in {
176
+ from {
177
+ opacity: 0;
178
+ transform: translateY(1rem);
179
+ }
180
+ }
181
+
182
+ @media (max-width: 599px) {
183
+ :where(.marvin-consent__actions) {
184
+ grid-auto-flow: row;
185
+ grid-auto-columns: auto;
186
+ flex: 1 1 100%;
187
+ }
188
+
189
+ :where(.marvin-consent__button) {
190
+ width: 100%;
191
+ }
192
+ }