@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,246 @@
1
+ /**
2
+ * The consent runtime behind `<ConsentBanner>`.
3
+ *
4
+ * It remembers one visitor choice per consent version in `localStorage['marvin-consent']`, turns
5
+ * inert markup on only for categories the visitor allowed, and exposes `window.marvinConsent`.
6
+ * Nothing optional runs until a valid choice exists, and without JavaScript nothing runs at all.
7
+ *
8
+ * Gating markup (the category list is space or comma separated; every listed category must be
9
+ * allowed, `necessary` always is):
10
+ *
11
+ * <script type="text/plain" data-consent="analytics" data-src="https://…"></script>
12
+ * <script type="text/plain" data-consent="analytics">inline code</script>
13
+ * <template data-consent="marketing"><iframe …></iframe></template>
14
+ *
15
+ * An unknown category never activates. `data-type="module"` restores a module type.
16
+ *
17
+ * This module has no imports so the browser bundle stays small (measured under 3 KB gzipped).
18
+ */
19
+
20
+ export type ConsentCategoryName = 'preferences' | 'analytics' | 'marketing';
21
+ export type ConsentChoices = Record<ConsentCategoryName, boolean>;
22
+
23
+ export type ConsentRecord = {
24
+ v: 1;
25
+ /** The `consentVersion()` the choice answers. */
26
+ version: string;
27
+ choices: ConsentChoices;
28
+ /** Epoch milliseconds the choice was made. */
29
+ at: number;
30
+ };
31
+
32
+ export type MarvinConsentApi = {
33
+ /** The current valid record, or `null` while the visitor still has to choose. */
34
+ get(): ConsentRecord | null;
35
+ /** `necessary` is always true; others need a valid choice that allows them. */
36
+ has(category: string): boolean;
37
+ /** Record a choice (unspecified categories keep their current value), then apply it. */
38
+ set(choices: Partial<ConsentChoices>): void;
39
+ /** Open the Customise dialog. */
40
+ open(): void;
41
+ };
42
+
43
+ export type ConsentDetail = { choices: ConsentChoices; version: string; initial: boolean };
44
+
45
+ export type InstallOptions = {
46
+ /** Reload after a withdrawal. Defaults to `location.reload()`. */
47
+ reload?: () => void;
48
+ /** Clock override for tests. */
49
+ now?: () => number;
50
+ };
51
+
52
+ declare global {
53
+ interface Window {
54
+ marvinConsent?: MarvinConsentApi;
55
+ }
56
+ }
57
+
58
+ export const CONSENT_KEY = 'marvin-consent';
59
+ const CATEGORIES: readonly ConsentCategoryName[] = ['preferences', 'analytics', 'marketing'];
60
+ const DAY = 864e5;
61
+ const GATED = 'script[type="text/plain"][data-consent],template[data-consent]';
62
+ const FACADE = 'button[data-marvin-embed-attrs],button[data-marvin-embed-src]';
63
+
64
+ export function installConsent(doc: Document, options: InstallOptions = {}): MarvinConsentApi | undefined {
65
+ const win = doc.defaultView as Window & typeof globalThis;
66
+ const root = doc.querySelector<HTMLElement>('[data-marvin-consent-banner]');
67
+ if (!win || !root) return undefined;
68
+ if (win.marvinConsent) return win.marvinConsent;
69
+
70
+ const now = options.now ?? Date.now;
71
+ const reload = options.reload ?? (() => win.location.reload());
72
+ const bar = root.querySelector<HTMLElement>('[data-marvin-consent-bar]');
73
+ const dialog = root.querySelector<HTMLDialogElement>('dialog');
74
+ const version = root.dataset.version ?? '';
75
+ const maxAge = (Number(root.dataset.maxAge) || 180) * DAY;
76
+ const offered = (root.dataset.categories ?? '').split(' ').filter(Boolean) as ConsentCategoryName[];
77
+ const embedsCategory = root.dataset.embeds;
78
+ let cookieNames: Record<string, string[]> = {};
79
+ try {
80
+ cookieNames = JSON.parse(root.dataset.cookies ?? '{}');
81
+ } catch {
82
+ /* no withdrawal cleanup, the reload still stops the scripts */
83
+ }
84
+
85
+ let record: ConsentRecord | null = null;
86
+ let opener: Element | null = null;
87
+
88
+ const read = (): ConsentRecord | null => {
89
+ try {
90
+ const raw = JSON.parse(win.localStorage.getItem(CONSENT_KEY) ?? 'null');
91
+ if (
92
+ raw?.v === 1 &&
93
+ raw.version === version &&
94
+ typeof raw.at === 'number' &&
95
+ now() - raw.at < maxAge &&
96
+ raw.choices
97
+ ) {
98
+ return {
99
+ v: 1,
100
+ version,
101
+ at: raw.at,
102
+ choices: Object.fromEntries(CATEGORIES.map((c) => [c, raw.choices[c] === true])) as ConsentChoices,
103
+ };
104
+ }
105
+ } catch {
106
+ /* storage blocked or corrupt: ask again */
107
+ }
108
+ return null;
109
+ };
110
+
111
+ const allowed = (category: string) => category === 'necessary' || record?.choices[category as ConsentCategoryName] === true;
112
+
113
+ const activate = () => {
114
+ doc.querySelectorAll<HTMLElement>(GATED).forEach((el) => {
115
+ const needs = (el.dataset.consent ?? '').split(/[\s,]+/).filter(Boolean);
116
+ if (!needs.length || !needs.every(allowed)) return;
117
+ if (el instanceof win.HTMLTemplateElement) {
118
+ el.replaceWith(doc.importNode(el.content, true));
119
+ return;
120
+ }
121
+ const script = doc.createElement('script');
122
+ script.async = false;
123
+ for (const { name, value } of Array.from(el.attributes)) {
124
+ if (!['type', 'data-consent', 'data-src', 'data-type'].includes(name)) script.setAttribute(name, value);
125
+ }
126
+ if (el.dataset.src) script.src = el.dataset.src;
127
+ if (el.dataset.type) script.type = el.dataset.type;
128
+ script.text = el.textContent ?? '';
129
+ el.replaceWith(script);
130
+ });
131
+ };
132
+
133
+ /** Delete first-party cookies by name; a trailing `*` matches a prefix. Host and parent domains. */
134
+ const expire = (patterns: string[] = []) => {
135
+ const present = doc.cookie.split(';').map((pair) => pair.split('=')[0]!.trim()).filter(Boolean);
136
+ const labels = win.location.hostname.split('.');
137
+ const domains = ['', ...labels.slice(0, -1).map((_, i) => labels.slice(i).join('.'))];
138
+ if (labels.length < 2) domains.push(win.location.hostname);
139
+ for (const pattern of patterns) {
140
+ const prefix = pattern.endsWith('*') ? pattern.slice(0, -1) : null;
141
+ for (const name of present.filter((n) => (prefix === null ? n === pattern : n.startsWith(prefix)))) {
142
+ for (const domain of domains) {
143
+ doc.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;max-age=0;path=/${domain ? `;domain=${domain}` : ''}`;
144
+ }
145
+ }
146
+ }
147
+ };
148
+
149
+ const emit = (initial: boolean) => {
150
+ if (!record) return;
151
+ const detail: ConsentDetail = { choices: { ...record.choices }, version, initial };
152
+ doc.dispatchEvent(new win.CustomEvent('marvin:consent', { detail }));
153
+ };
154
+
155
+ const loadEmbeds = () => {
156
+ // The facades' own delegated handler does the work; this only presses them.
157
+ if (embedsCategory && allowed(embedsCategory)) doc.querySelectorAll<HTMLElement>(FACADE).forEach((b) => b.click());
158
+ };
159
+
160
+ const focusAfter = () => {
161
+ const back = opener && doc.contains(opener) && (!bar?.contains(opener) || !bar.hidden) ? opener : null;
162
+ const target = (back ?? doc.querySelector<HTMLElement>('main,[role="main"]')) as HTMLElement | null;
163
+ if (target && !target.matches('a,button,input,select,textarea,[tabindex]')) target.setAttribute('tabindex', '-1');
164
+ target?.focus({ preventScroll: true });
165
+ };
166
+
167
+ const closeDialog = () => {
168
+ if (dialog?.open) typeof dialog.close === 'function' ? dialog.close() : dialog.removeAttribute('open');
169
+ focusAfter();
170
+ };
171
+
172
+ const api: MarvinConsentApi = {
173
+ get: () => (record ? { ...record, choices: { ...record.choices } } : null),
174
+ has: allowed,
175
+ set(choices) {
176
+ const previous = record;
177
+ const next: ConsentChoices = { preferences: false, analytics: false, marketing: false };
178
+ for (const c of CATEGORIES) next[c] = choices[c] ?? previous?.choices[c] ?? false;
179
+ record = { v: 1, version, choices: next, at: now() };
180
+ try {
181
+ win.localStorage.setItem(CONSENT_KEY, JSON.stringify(record));
182
+ } catch {
183
+ /* kept in memory for this page only */
184
+ }
185
+ CATEGORIES.filter((c) => !next[c]).forEach((c) => expire(cookieNames[c]));
186
+ if (bar) bar.hidden = true;
187
+ emit(false);
188
+ if (CATEGORIES.some((c) => previous?.choices[c] && !next[c])) {
189
+ reload();
190
+ return;
191
+ }
192
+ activate();
193
+ loadEmbeds();
194
+ },
195
+ open() {
196
+ if (!dialog) return;
197
+ dialog.querySelectorAll<HTMLInputElement>('input[data-marvin-consent-category]').forEach((input) => {
198
+ input.checked = record?.choices[input.dataset.marvinConsentCategory as ConsentCategoryName] === true;
199
+ });
200
+ if (typeof dialog.showModal === 'function') dialog.showModal();
201
+ else dialog.setAttribute('open', '');
202
+ },
203
+ };
204
+
205
+ const choose = (accept: boolean) =>
206
+ api.set(Object.fromEntries(CATEGORIES.map((c) => [c, accept && offered.includes(c)])));
207
+
208
+ doc.addEventListener('click', (event) => {
209
+ const mouse = event as MouseEvent;
210
+ const target = (event.target as Element | null)?.closest?.('[data-marvin-consent-action],[data-marvin-consent-open]');
211
+ if (!target || mouse.button || mouse.metaKey || mouse.ctrlKey || mouse.shiftKey) return;
212
+ event.preventDefault();
213
+ const action = target.getAttribute('data-marvin-consent-action');
214
+ if (!action || action === 'customise') {
215
+ opener = target;
216
+ api.open();
217
+ return;
218
+ }
219
+ if (action === 'save') {
220
+ const picked: Partial<ConsentChoices> = {};
221
+ dialog?.querySelectorAll<HTMLInputElement>('input[data-marvin-consent-category]').forEach((input) => {
222
+ picked[input.dataset.marvinConsentCategory as ConsentCategoryName] = input.checked;
223
+ });
224
+ api.set(picked);
225
+ } else {
226
+ opener = target;
227
+ choose(action === 'accept');
228
+ }
229
+ closeDialog();
230
+ });
231
+ dialog?.addEventListener('close', focusAfter);
232
+
233
+ win.marvinConsent = api;
234
+ record = read();
235
+ if (record) {
236
+ activate();
237
+ // Listeners attached during page load (later module scripts) must not miss the initial event.
238
+ win.setTimeout(() => {
239
+ emit(true);
240
+ loadEmbeds();
241
+ }, 0);
242
+ } else if (bar) {
243
+ bar.hidden = false;
244
+ }
245
+ return api;
246
+ }
@@ -22,8 +22,20 @@ export { default as FormRenderer } from './FormRenderer.astro';
22
22
  export { default as Embed } from './Embed.astro';
23
23
  export { default as LinkCard } from './LinkCard.astro';
24
24
  export { default as EmbedLoader } from './EmbedLoader.astro';
25
+ export { default as CookieTable } from './CookieTable.astro';
26
+ export { default as ProcessorTable } from './ProcessorTable.astro';
27
+ export { default as PrivacyPolicy } from './PrivacyPolicy.astro';
28
+ export { default as ConsentBanner } from './ConsentBanner.astro';
29
+ export { default as CookieSettingsLink } from './CookieSettingsLink.astro';
25
30
  export { marvinIntegration } from './integration.js';
26
31
  export { iframeAttributes, installEmbedLoader, isAllowedFrameSrc } from './embed-frame.js';
32
+ export { installConsent } from './consent.js';
33
+ export type {
34
+ ConsentChoices,
35
+ ConsentDetail,
36
+ ConsentRecord,
37
+ MarvinConsentApi,
38
+ } from './consent.js';
27
39
 
28
40
  // The framework-agnostic renderer logic, so one import covers a renderer component's needs.
29
41
  export * from '../renderers/index.js';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Neutral English defaults for the privacy and consent components. Every string can be replaced
3
+ * per site through the components' `labels` props; Marvin-editable text is a later step.
4
+ */
5
+ import type { CookieCategory } from '../privacy.js';
6
+
7
+ /** Default consent lifetime, ~6 months. Refusals last as long as acceptances. */
8
+ export const DEFAULT_MAX_AGE_DAYS = 180;
9
+
10
+ /** The storage key the consent runtime writes; disclosed as a necessary entry. */
11
+ export const CONSENT_STORAGE_KEY = 'marvin-consent';
12
+
13
+ export const CATEGORY_LABELS: Record<CookieCategory, string> = {
14
+ necessary: 'Necessary',
15
+ preferences: 'Preferences',
16
+ analytics: 'Analytics',
17
+ marketing: 'Marketing',
18
+ };
19
+
20
+ export const CATEGORY_DESCRIPTIONS: Record<CookieCategory, string> = {
21
+ necessary: 'Needed for the site to work, or to remember a choice you made. Always on.',
22
+ preferences: 'Remember settings such as your language or theme.',
23
+ analytics: 'Help us understand how the site is used so we can improve it.',
24
+ marketing: 'Used to show relevant content or measure campaigns.',
25
+ };
26
+
27
+ export type PrivacyLabels = {
28
+ // Tables
29
+ cookieName: string;
30
+ provider: string;
31
+ purpose: string;
32
+ duration: string;
33
+ processor: string;
34
+ data: string;
35
+ region: string;
36
+ transferBasis: string;
37
+ links: string;
38
+ dpa: string;
39
+ privacyPolicy: string;
40
+ thisSite: string;
41
+ consentCookiePurpose: string;
42
+ noscript: string;
43
+ // Policy page
44
+ controller: string;
45
+ legalName: string;
46
+ address: string;
47
+ contact: string;
48
+ euRepresentative: string;
49
+ dpo: string;
50
+ processors: string;
51
+ cookies: string;
52
+ lastReviewed: string;
53
+ changeSettings: string;
54
+ categories: Record<CookieCategory, string>;
55
+ };
56
+
57
+ export const DEFAULT_PRIVACY_LABELS: PrivacyLabels = {
58
+ cookieName: 'Name',
59
+ provider: 'Provider',
60
+ purpose: 'Purpose',
61
+ duration: 'Duration',
62
+ processor: 'Processor',
63
+ data: 'Data',
64
+ region: 'Region',
65
+ transferBasis: 'Transfer basis',
66
+ links: 'Links',
67
+ dpa: 'Data processing agreement',
68
+ privacyPolicy: 'Privacy policy',
69
+ thisSite: 'This site',
70
+ consentCookiePurpose: 'Remembers your cookie choices.',
71
+ noscript: 'Optional cookies are off because JavaScript is disabled.',
72
+ controller: 'Who is responsible for your data',
73
+ legalName: 'Controller',
74
+ address: 'Address',
75
+ contact: 'Contact',
76
+ euRepresentative: 'EU representative',
77
+ dpo: 'Data protection officer',
78
+ processors: 'Who else handles your data',
79
+ cookies: 'Cookies and similar storage',
80
+ lastReviewed: 'Last reviewed',
81
+ changeSettings: 'Change your cookie settings',
82
+ categories: CATEGORY_LABELS,
83
+ };
84
+
85
+ /** `labels` merged over the defaults (one level deep, plus `categories`). */
86
+ export function privacyLabels(overrides?: Partial<Omit<PrivacyLabels, 'categories'>> & {
87
+ categories?: Partial<Record<CookieCategory, string>>;
88
+ }): PrivacyLabels {
89
+ return {
90
+ ...DEFAULT_PRIVACY_LABELS,
91
+ ...overrides,
92
+ categories: { ...CATEGORY_LABELS, ...overrides?.categories },
93
+ };
94
+ }
95
+
96
+ /** 180 → "6 months", 365 → "12 months", 30 → "30 days". */
97
+ export function formatMaxAge(days: number): string {
98
+ if (days >= 60) return `${Math.round(days / 30)} months`;
99
+ return `${days} ${days === 1 ? 'day' : 'days'}`;
100
+ }
101
+
102
+ export type ConsentLabels = {
103
+ title: string;
104
+ body: string;
105
+ acceptAll: string;
106
+ rejectAll: string;
107
+ customise: string;
108
+ save: string;
109
+ /** The "Cookie settings" link (`<CookieSettingsLink>`). */
110
+ settingsLink: string;
111
+ /** The link to the policy page, shown when `policyHref` is given. */
112
+ policyLink: string;
113
+ dialogTitle: string;
114
+ dialogBody: string;
115
+ cookiesList: string;
116
+ categories: Record<CookieCategory, string>;
117
+ categoryDescriptions: Record<CookieCategory, string>;
118
+ };
119
+
120
+ export const DEFAULT_CONSENT_LABELS: ConsentLabels = {
121
+ title: 'Your privacy choices',
122
+ body: 'This site would like to use optional cookies and similar storage. Necessary ones are always on. You can accept all, reject all, or choose.',
123
+ acceptAll: 'Accept all',
124
+ rejectAll: 'Reject all',
125
+ customise: 'Customise',
126
+ save: 'Save choices',
127
+ settingsLink: 'Cookie settings',
128
+ policyLink: 'Cookie policy',
129
+ dialogTitle: 'Cookie settings',
130
+ dialogBody: 'Choose which optional cookies this site may use. You can change this at any time.',
131
+ cookiesList: 'Cookies',
132
+ categories: CATEGORY_LABELS,
133
+ categoryDescriptions: CATEGORY_DESCRIPTIONS,
134
+ };
135
+
136
+ export type ConsentLabelOverrides = Partial<Omit<ConsentLabels, 'categories' | 'categoryDescriptions'>> & {
137
+ categories?: Partial<Record<CookieCategory, string>>;
138
+ categoryDescriptions?: Partial<Record<CookieCategory, string>>;
139
+ };
140
+
141
+ /** `labels` merged over the defaults. */
142
+ export function consentLabels(overrides?: ConsentLabelOverrides): ConsentLabels {
143
+ return {
144
+ ...DEFAULT_CONSENT_LABELS,
145
+ ...overrides,
146
+ categories: { ...CATEGORY_LABELS, ...overrides?.categories },
147
+ categoryDescriptions: { ...CATEGORY_DESCRIPTIONS, ...overrides?.categoryDescriptions },
148
+ };
149
+ }
@@ -0,0 +1,150 @@
1
+ /*
2
+ * Base styles for the privacy & cookie policy components.
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-privacy-gap space between sections (default 2rem)
8
+ * --marvin-privacy-border table rule colour (default currentColor at 20%)
9
+ * --marvin-privacy-head-bg table header background (default currentColor at 6%)
10
+ * --marvin-privacy-muted secondary text (default currentColor at 75%)
11
+ * --marvin-privacy-pad table cell padding (default 0.5rem 0.75rem)
12
+ * --marvin-privacy-breakpoint documentation only; tables stack below 600px
13
+ *
14
+ * Markup:
15
+ * div.marvin-privacy
16
+ * section.marvin-privacy__section (controller, processors, cookies)
17
+ * dl.marvin-privacy__facts
18
+ * table.marvin-privacy__table.marvin-privacy__table--{cookies|processors}
19
+ */
20
+
21
+ :where(.marvin-privacy) {
22
+ display: grid;
23
+ /* A bare `1fr`/auto track grows to its widest child's min-content, so one wide table would push
24
+ the whole policy past the viewport. minmax(0, 1fr) keeps the track at the container's width. */
25
+ grid-template-columns: minmax(0, 1fr);
26
+ gap: var(--marvin-privacy-gap, 2rem);
27
+ }
28
+
29
+ :where(.marvin-privacy__section > :first-child) {
30
+ margin-top: 0;
31
+ }
32
+
33
+ :where(.marvin-privacy__facts) {
34
+ display: grid;
35
+ grid-template-columns: max-content 1fr;
36
+ gap: 0.25rem 1.5rem;
37
+ margin: 0;
38
+ }
39
+
40
+ :where(.marvin-privacy__facts dt) {
41
+ font-weight: 600;
42
+ }
43
+
44
+ :where(.marvin-privacy__facts dd) {
45
+ margin: 0;
46
+ min-width: 0;
47
+ overflow-wrap: break-word;
48
+ }
49
+
50
+ :where(.marvin-privacy__notice, .marvin-privacy__reviewed) {
51
+ color: var(--marvin-privacy-muted, color-mix(in srgb, currentColor 75%, transparent));
52
+ }
53
+
54
+ :where(.marvin-privacy__table-wrap) {
55
+ /* Safety net: if a table still cannot fit (many columns in a narrow column, a long URL in a
56
+ purpose cell) it scrolls inside its own box instead of stretching the page. */
57
+ max-width: 100%;
58
+ overflow-x: auto;
59
+ }
60
+
61
+ :where(.marvin-privacy__table) {
62
+ width: 100%;
63
+ border-collapse: collapse;
64
+ margin: 0 0 1.5rem;
65
+ text-align: left;
66
+ }
67
+
68
+ :where(.marvin-privacy__table caption) {
69
+ text-align: left;
70
+ font-weight: 600;
71
+ padding-bottom: 0.5rem;
72
+ }
73
+
74
+ :where(.marvin-privacy__table caption small) {
75
+ display: block;
76
+ font-weight: 400;
77
+ color: var(--marvin-privacy-muted, color-mix(in srgb, currentColor 75%, transparent));
78
+ }
79
+
80
+ :where(.marvin-privacy__table th, .marvin-privacy__table td) {
81
+ padding: var(--marvin-privacy-pad, 0.5rem 0.75rem);
82
+ border-bottom: 1px solid var(--marvin-privacy-border, color-mix(in srgb, currentColor 20%, transparent));
83
+ vertical-align: top;
84
+ /* Ordinary words wrap at normal break points. `break-word` (unlike `anywhere`) does not shrink a
85
+ column's min-content width, so a narrow column never splits a word that could sit on its own
86
+ line. Only an unbreakable token wider than its cell is broken, below. */
87
+ overflow-wrap: break-word;
88
+ }
89
+
90
+ /* Long unbreakable tokens (cookie names such as _ga_XXXXXXXX, addresses) may break anywhere, and
91
+ `anywhere` lets their column shrink so the table stays inside its container. */
92
+ :where(.marvin-privacy__table code, .marvin-privacy__table a[href^="mailto:"], .marvin-privacy__facts a[href^="mailto:"]) {
93
+ overflow-wrap: anywhere;
94
+ }
95
+
96
+ :where(.marvin-privacy__table thead th) {
97
+ background: var(--marvin-privacy-head-bg, color-mix(in srgb, currentColor 6%, transparent));
98
+ }
99
+
100
+ :where(.marvin-privacy__table tbody th) {
101
+ font-weight: 600;
102
+ }
103
+
104
+ :where(.marvin-privacy__links) {
105
+ margin: 0;
106
+ padding: 0;
107
+ list-style: none;
108
+ }
109
+
110
+ /* Below 600px each row becomes a stacked card: the header row is hidden visually (still read by
111
+ assistive tech) and every cell carries its column name from data-label. */
112
+ @media (max-width: 599px) {
113
+ :where(.marvin-privacy__table, .marvin-privacy__table tbody, .marvin-privacy__table tr, .marvin-privacy__table th, .marvin-privacy__table td) {
114
+ display: block;
115
+ }
116
+
117
+ /* The caption is otherwise sized as a shrink-to-fit table-caption and wraps in a sliver. */
118
+ :where(.marvin-privacy__table caption) {
119
+ display: block;
120
+ width: 100%;
121
+ }
122
+
123
+ :where(.marvin-privacy__table thead) {
124
+ position: absolute;
125
+ width: 1px;
126
+ height: 1px;
127
+ overflow: hidden;
128
+ clip-path: inset(50%);
129
+ white-space: nowrap;
130
+ }
131
+
132
+ :where(.marvin-privacy__table tr) {
133
+ padding: 0.5rem 0;
134
+ border-bottom: 1px solid var(--marvin-privacy-border, color-mix(in srgb, currentColor 20%, transparent));
135
+ }
136
+
137
+ :where(.marvin-privacy__table th, .marvin-privacy__table td) {
138
+ border-bottom: 0;
139
+ padding-block: 0.125rem;
140
+ }
141
+
142
+ :where(.marvin-privacy__table td[data-label])::before {
143
+ content: attr(data-label) ': ';
144
+ font-weight: 600;
145
+ }
146
+
147
+ :where(.marvin-privacy__facts) {
148
+ grid-template-columns: 1fr;
149
+ }
150
+ }
package/src/index.ts CHANGED
@@ -137,6 +137,14 @@ export {
137
137
  type RenderMarkdownOptions,
138
138
  } from './markdown.js';
139
139
  export { entryEmbeds, findEmbed, siteEmbeds } from './embeds.js';
140
+ export {
141
+ COOKIE_CATEGORIES,
142
+ CONSENT_CATEGORIES,
143
+ consentCategories,
144
+ consentVersion,
145
+ cookiesNeedConsent,
146
+ sitePrivacy,
147
+ } from './privacy.js';
140
148
  export * from './normalize.js';
141
149
  export {
142
150
  createRepository,