@c15t/astro 3.0.0-alpha.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.
Files changed (117) hide show
  1. package/AGENTS.md +86 -0
  2. package/SKILL.md +14 -0
  3. package/dist/api/handlers.js +91 -0
  4. package/dist/api/index.js +3 -0
  5. package/dist/api/init.js +10 -0
  6. package/dist/api/manifest-init.js +103 -0
  7. package/dist/api/manifest.js +10 -0
  8. package/dist/browser/iab.js +5 -0
  9. package/dist/browser/inline-scripts.js +47 -0
  10. package/dist/client.js +276 -0
  11. package/dist/index.js +5 -0
  12. package/dist/integration.js +170 -0
  13. package/dist/libs/cookies.js +9 -0
  14. package/dist/middleware-handler.js +31 -0
  15. package/dist/middleware.js +6 -0
  16. package/dist/mode.js +50 -0
  17. package/dist/server.js +291 -0
  18. package/dist/styles.css +2 -0
  19. package/dist/types.js +0 -0
  20. package/dist/ui/adapter.js +37 -0
  21. package/dist/ui/provider-props.js +12 -0
  22. package/dist/ui/react.js +34 -0
  23. package/dist/ui/svelte.js +36 -0
  24. package/dist/ui/vue.js +38 -0
  25. package/dist-types/api/handlers.d.ts +85 -0
  26. package/dist-types/api/index.d.ts +13 -0
  27. package/dist-types/api/init.d.ts +10 -0
  28. package/dist-types/api/manifest-init.d.ts +73 -0
  29. package/dist-types/api/manifest.d.ts +9 -0
  30. package/dist-types/browser/iab.d.ts +14 -0
  31. package/dist-types/browser/inline-scripts.d.ts +38 -0
  32. package/dist-types/client.d.ts +152 -0
  33. package/dist-types/index.d.ts +33 -0
  34. package/dist-types/integration.d.ts +53 -0
  35. package/dist-types/libs/cookies.d.ts +15 -0
  36. package/dist-types/middleware-handler.d.ts +35 -0
  37. package/dist-types/middleware.d.ts +20 -0
  38. package/dist-types/mode.d.ts +75 -0
  39. package/dist-types/server.d.ts +130 -0
  40. package/dist-types/types.d.ts +305 -0
  41. package/dist-types/ui/adapter.d.ts +122 -0
  42. package/dist-types/ui/provider-props.d.ts +37 -0
  43. package/dist-types/ui/react.d.ts +19 -0
  44. package/dist-types/ui/svelte.d.ts +21 -0
  45. package/dist-types/ui/vue.d.ts +19 -0
  46. package/docs/README.md +86 -0
  47. package/docs/assets/v3/brand-bar.png +0 -0
  48. package/docs/assets/v3/brand-card.png +0 -0
  49. package/docs/assets/v3/choice-wall.png +0 -0
  50. package/docs/assets/v3/mobile-card.png +0 -0
  51. package/docs/assets/v3/preferences.png +0 -0
  52. package/docs/customization/overview.md +45 -0
  53. package/docs/customization/recipes.md +79 -0
  54. package/docs/customization/slots.md +55 -0
  55. package/docs/customization/tokens.md +76 -0
  56. package/docs/customization/translations.md +49 -0
  57. package/docs/frameworks/astro/quickstart.md +186 -0
  58. package/docs/guides/consent-state.md +60 -0
  59. package/docs/guides/data-fetching.md +163 -0
  60. package/docs/guides/deployment-modes.md +63 -0
  61. package/docs/guides/troubleshooting.md +68 -0
  62. package/docs/guides/verify-consent.md +62 -0
  63. package/docs/integrations/adobe-analytics.md +294 -0
  64. package/docs/integrations/ahrefs-analytics.md +291 -0
  65. package/docs/integrations/amplitude.md +292 -0
  66. package/docs/integrations/building-integrations.md +47 -0
  67. package/docs/integrations/clearbit.md +291 -0
  68. package/docs/integrations/cloudflare-web-analytics.md +292 -0
  69. package/docs/integrations/crisp.md +294 -0
  70. package/docs/integrations/databuddy.md +311 -0
  71. package/docs/integrations/fathom-analytics.md +293 -0
  72. package/docs/integrations/google-maps.md +384 -0
  73. package/docs/integrations/google-tag-manager.md +306 -0
  74. package/docs/integrations/google-tag.md +305 -0
  75. package/docs/integrations/heap.md +292 -0
  76. package/docs/integrations/hightouch.md +293 -0
  77. package/docs/integrations/hotjar.md +292 -0
  78. package/docs/integrations/intercom.md +285 -0
  79. package/docs/integrations/linkedin-insights.md +291 -0
  80. package/docs/integrations/logrocket.md +293 -0
  81. package/docs/integrations/matomo-analytics.md +316 -0
  82. package/docs/integrations/meta-pixel.md +285 -0
  83. package/docs/integrations/microsoft-clarity.md +292 -0
  84. package/docs/integrations/microsoft-uet.md +291 -0
  85. package/docs/integrations/mixpanel-analytics.md +295 -0
  86. package/docs/integrations/openai-pixel.md +441 -0
  87. package/docs/integrations/overview.md +111 -0
  88. package/docs/integrations/pirsch.md +295 -0
  89. package/docs/integrations/plausible-analytics.md +295 -0
  90. package/docs/integrations/posthog.md +397 -0
  91. package/docs/integrations/promptwatch.md +291 -0
  92. package/docs/integrations/reddit-pixel.md +294 -0
  93. package/docs/integrations/rudderstack.md +328 -0
  94. package/docs/integrations/rybbit-analytics.md +296 -0
  95. package/docs/integrations/segment.md +292 -0
  96. package/docs/integrations/snapchat-pixel.md +293 -0
  97. package/docs/integrations/tiktok-pixel.md +291 -0
  98. package/docs/integrations/umami-analytics.md +295 -0
  99. package/docs/integrations/vercel-analytics.md +293 -0
  100. package/docs/integrations/x-pixel.md +291 -0
  101. package/docs/integrations/youtube.md +405 -0
  102. package/docs/upgrade-v3.md +334 -0
  103. package/package.json +184 -0
  104. package/src/components/branding.astro +152 -0
  105. package/src/components/consent-script.astro +34 -0
  106. package/src/components/iab-panel.astro +43 -0
  107. package/src/components/iab-prompt.astro +316 -0
  108. package/src/components/islands/iab-dialog-surface.svelte +15 -0
  109. package/src/components/islands/iab-dialog-surface.tsx +25 -0
  110. package/src/components/islands/iab-dialog-surface.vue +20 -0
  111. package/src/components/islands/panel-surface.svelte +57 -0
  112. package/src/components/islands/panel-surface.tsx +67 -0
  113. package/src/components/islands/panel-surface.vue +39 -0
  114. package/src/components/panel-trigger.astro +48 -0
  115. package/src/components/panel.astro +65 -0
  116. package/src/components/prompt-deferred.astro +54 -0
  117. package/src/components/prompt.astro +325 -0
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * Inlines the server-resolved consent config for the browser.
4
+ *
5
+ * Put this in your layout's `<head>`. It hands the client boot script the
6
+ * decision the server already made — consents, policy, translations — so
7
+ * the page boots with no `/init` roundtrip and no banner flicker, and it
8
+ * writes the colour-scheme class before the stylesheet paints, so a
9
+ * system-dark visitor does not get a light banner for a frame.
10
+ *
11
+ * `<ConsentBanner />` renders both itself if they have not been emitted
12
+ * yet, so a site with a banner in every layout does not need this
13
+ * explicitly — but `<head>` is where the colour-scheme script belongs.
14
+ */
15
+ import {
16
+ buildColorSchemeScript,
17
+ buildConfigScript,
18
+ markConfigEmitted,
19
+ } from '@c15t/astro/server';
20
+
21
+ const { c15t } = Astro.locals;
22
+ const shouldEmit = c15t !== undefined && markConfigEmitted(Astro.locals);
23
+ const colorSchemeScript = shouldEmit
24
+ ? buildColorSchemeScript(c15t.options.colorScheme)
25
+ : '';
26
+ ---
27
+
28
+ {colorSchemeScript && <script is:inline set:html={colorSchemeScript} />}
29
+
30
+ {
31
+ shouldEmit && (
32
+ <script is:inline set:html={buildConfigScript(c15t.config)} />
33
+ )
34
+ }
@@ -0,0 +1,43 @@
1
+ ---
2
+ /**
3
+ * The IAB TCF preference centre.
4
+ *
5
+ * Like `<ConsentDialog />`, this renders only a host element; the TCF
6
+ * surface and `@c15t/iab` are both loaded on first open, so a site with IAB
7
+ * configured still serves a banner-only page with no TCF bytes.
8
+ *
9
+ * ```astro
10
+ * ---
11
+ * import IABConsentDialog from '@c15t/astro/components/iab-consent-dialog.astro';
12
+ * ---
13
+ * <IABConsentDialog />
14
+ * ```
15
+ */
16
+ interface Props {
17
+ /** Warm the island's chunk once the browser is idle. */
18
+ preload?: boolean;
19
+ }
20
+
21
+ const { preload = false } = Astro.props;
22
+ ---
23
+
24
+ <div data-c15t-dialog-host="iab" data-preload={preload ? 'true' : undefined}></div>
25
+
26
+ <script>
27
+ import { preloadDialog } from '@c15t/astro/client';
28
+
29
+ // The integration registers the island for the configured `ui`; see
30
+ // `<ConsentDialog />` for why the specifier does not live here.
31
+ const host = document.querySelector('[data-c15t-dialog-host="iab"]');
32
+
33
+ if (host?.getAttribute('data-preload') === 'true') {
34
+ const schedule = () => {
35
+ void preloadDialog();
36
+ };
37
+ if ('requestIdleCallback' in window) {
38
+ window.requestIdleCallback(schedule);
39
+ } else {
40
+ window.setTimeout(schedule, 2000);
41
+ }
42
+ }
43
+ </script>
@@ -0,0 +1,316 @@
1
+ ---
2
+ /**
3
+ * Server-rendered IAB TCF banner.
4
+ *
5
+ * The same deal as `<ConsentBanner />`: markup, copy and the purposes
6
+ * summary are produced on the server with no framework JavaScript, and the
7
+ * only client code is the delegated `data-c15t-action` handler every banner
8
+ * on the site already shares. `customize` opens the TCF preference centre
9
+ * through `<IABConsentDialog />`'s island, so the TCF surface downloads
10
+ * only when someone asks for it.
11
+ *
12
+ * The summary — which purposes, stacks and special features the banner
13
+ * names, how many it leaves out, how many vendors there are — comes from
14
+ * the shared model in `@c15t/iab/headless`, the same one the React, Svelte
15
+ * and Vue banners read. Same DOM shape and the same `data-testid`s, so the
16
+ * cross-framework parity gate compares like with like.
17
+ *
18
+ * It renders only when the server already has a vendor list. Hosted and
19
+ * manifest mode get one from `/init`; offline mode needs `iab.gvl` or
20
+ * `iab.gvlURL` on the integration options.
21
+ *
22
+ * ```astro
23
+ * ---
24
+ * import IABConsentBanner from '@c15t/astro/components/iab-consent-banner.astro';
25
+ * import IABConsentDialog from '@c15t/astro/components/iab-consent-dialog.astro';
26
+ * ---
27
+ * <IABConsentBanner />
28
+ * <IABConsentDialog />
29
+ * ```
30
+ */
31
+ import { defaultTranslationConfig, resolveConsentPresentation } from '@c15t/core';
32
+ import type { TranslationsResponse } from '@c15t/core';
33
+ import { resolveIABBannerSummary } from '@c15t/iab/headless';
34
+ import {
35
+ buildColorSchemeScript,
36
+ buildConfigScript,
37
+ markConfigEmitted,
38
+ } from '@c15t/astro/server';
39
+ import Branding from './branding.astro';
40
+ import actionStyles from '@c15t/ui/styles/components/consent-actions';
41
+ import styles from '@c15t/ui/styles/components/iab-consent-banner';
42
+ import buttonStyles from '@c15t/ui/styles/components/button';
43
+ import { getTextDirection } from '@c15t/ui/utils';
44
+
45
+ type IABAction = 'reject' | 'accept' | 'customize';
46
+
47
+ interface Props {
48
+ /** Which action gets the filled treatment. */
49
+ primaryButton?: IABAction;
50
+ /**
51
+ * Which consent models this banner responds to. Matches the `models`
52
+ * prop on the React, Svelte and Vue IAB banners.
53
+ */
54
+ models?: string[];
55
+ /** Ship the DOM without the bundled stylesheet's class names. */
56
+ noStyle?: boolean;
57
+ /** Extra class on the banner root. */
58
+ class?: string;
59
+ /** Drop the "Secured by c15t" tag. */
60
+ hideBranding?: boolean;
61
+ /** Paint the backdrop that goes with the banner's `aria-modal`. */
62
+ scrollLock?: boolean;
63
+ /**
64
+ * Render even when the server decided the banner should stay hidden.
65
+ * Useful for visual testing.
66
+ */
67
+ force?: boolean;
68
+ }
69
+
70
+ const {
71
+ primaryButton = 'customize',
72
+ models = ['iab'],
73
+ noStyle = false,
74
+ class: className,
75
+ hideBranding = false,
76
+ scrollLock,
77
+ force = false,
78
+ } = Astro.props;
79
+
80
+ const { c15t } = Astro.locals;
81
+
82
+ if (!c15t) {
83
+ throw new Error(
84
+ '@c15t/astro: `Astro.locals.c15t` is missing. The c15t() integration registers the middleware that populates it — check it is listed in astro.config, and that you have not set `middleware: false`.'
85
+ );
86
+ }
87
+
88
+ const { snapshot } = c15t;
89
+ const { blocking } = resolveConsentPresentation({
90
+ override: { scrollLock },
91
+ policy: snapshot.policyRule,
92
+ presentation: c15t.options.presentation,
93
+ surface: 'prompt',
94
+ });
95
+ const emitConfig = markConfigEmitted(Astro.locals);
96
+ const colorSchemeScript = emitConfig
97
+ ? buildColorSchemeScript(c15t.options.colorScheme)
98
+ : '';
99
+
100
+ const summary = resolveIABBannerSummary(
101
+ snapshot.iab?.enabled === false ? null : (snapshot.iab ?? null)
102
+ );
103
+ // No vendor list, no banner: every line of copy below interpolates a count
104
+ // the summary derives from it. The browser boots, fetches one and mounts
105
+ // the client banner instead.
106
+ const shouldRender =
107
+ c15t.hasConsentUi &&
108
+ (force || c15t.shouldShowBanner) &&
109
+ summary.isReady &&
110
+ models.includes(snapshot.policyRule.model);
111
+
112
+ const fallback = defaultTranslationConfig.translations.en as TranslationsResponse;
113
+ const bundle = (snapshot.translations?.translations ?? fallback) as TranslationsResponse;
114
+ const iabFallback = fallback.iab as NonNullable<TranslationsResponse['iab']>;
115
+ const iab = (bundle.iab ?? iabFallback) as NonNullable<TranslationsResponse['iab']>;
116
+ const copy = {
117
+ accept: iab.common?.acceptAll ?? iabFallback.common.acceptAll,
118
+ andMore: iab.banner?.andMore ?? iabFallback.banner.andMore,
119
+ customize: iab.common?.customize ?? iabFallback.common.customize,
120
+ description: iab.banner?.description ?? iabFallback.banner.description,
121
+ legitimateInterestNotice:
122
+ iab.banner?.legitimateInterestNotice ??
123
+ iabFallback.banner.legitimateInterestNotice,
124
+ partnersLink: iab.banner?.partnersLink ?? iabFallback.banner.partnersLink,
125
+ reject: iab.common?.rejectAll ?? iabFallback.common.rejectAll,
126
+ scopeServiceSpecific:
127
+ iab.banner?.scopeServiceSpecific ?? iabFallback.banner.scopeServiceSpecific,
128
+ securedBy: bundle.common?.securedBy ?? fallback.common.securedBy,
129
+ title: iab.banner?.title ?? iabFallback.banner.title,
130
+ };
131
+
132
+ const textDirection = getTextDirection(snapshot.translations?.language);
133
+
134
+ const descriptionText = copy.description.replace(
135
+ '{partnerCount}',
136
+ String(summary.vendorCount)
137
+ );
138
+ const partnersLinkText = copy.partnersLink.replace(
139
+ '{count}',
140
+ String(summary.vendorCount)
141
+ );
142
+ // The link sits inside the sentence, so the copy is split around it rather
143
+ // than concatenated — a translation is free to put it anywhere.
144
+ const [descriptionBefore = descriptionText, descriptionAfter = ''] =
145
+ descriptionText.split(partnersLinkText);
146
+
147
+ const labels: Record<IABAction, string> = {
148
+ accept: copy.accept,
149
+ customize: copy.customize,
150
+ reject: copy.reject,
151
+ };
152
+
153
+ /**
154
+ * The shared button stylesheet keys its variants off `data-*`. `reject` is
155
+ * never filled even when it is the primary action, which is what the React
156
+ * and Svelte banners do too.
157
+ */
158
+ const buttonAttrs = function buttonAttrs(action: IABAction) {
159
+ const primary = action === primaryButton;
160
+ return {
161
+ mode: primary && action !== 'reject' ? 'filled' : 'stroke',
162
+ variant: primary ? 'primary' : 'neutral',
163
+ };
164
+ };
165
+
166
+ const rootClass = [noStyle ? '' : styles.root, noStyle ? '' : styles.bannerVisible, className]
167
+ .filter(Boolean)
168
+ .join(' ');
169
+ ---
170
+
171
+ {
172
+ emitConfig && colorSchemeScript && (
173
+ <script is:inline set:html={colorSchemeScript} />
174
+ )
175
+ }
176
+
177
+ {emitConfig && <script is:inline set:html={buildConfigScript(c15t.config)} />}
178
+
179
+ {
180
+ shouldRender && blocking && (
181
+ <div
182
+ aria-hidden="true"
183
+ class={noStyle ? '' : `${styles.overlay} ${styles.overlayVisible}`}
184
+ data-testid="iab-consent-banner-overlay"
185
+ />
186
+ )
187
+ }
188
+
189
+ {
190
+ shouldRender && (
191
+ <div
192
+ class={rootClass}
193
+ data-c15t-visible="true"
194
+ data-blocking={blocking ? 'true' : undefined}
195
+ data-position={textDirection === 'ltr' ? 'bottom-left' : 'bottom-right'}
196
+ data-testid="iab-consent-banner-root"
197
+ dir={textDirection}
198
+ lang={snapshot.translations?.language}
199
+ tabindex="-1"
200
+ >
201
+ <div class={noStyle ? '' : styles.cardShell}>
202
+ <Branding
203
+ branding={snapshot.branding}
204
+ data-testid="iab-consent-banner-branding"
205
+ hide={hideBranding}
206
+ hostname={Astro.url.hostname}
207
+ noStyle={noStyle}
208
+ securedBy={copy.securedBy}
209
+ variant="banner-tag"
210
+ />
211
+ <div
212
+ aria-label={copy.title}
213
+ aria-modal={blocking ? 'true' : undefined}
214
+ class={noStyle ? '' : styles.card}
215
+ data-testid="iab-consent-banner-card"
216
+ role={blocking ? 'dialog' : 'region'}
217
+ >
218
+ <div
219
+ class={noStyle ? '' : styles.header}
220
+ data-testid="iab-consent-banner-header"
221
+ >
222
+ <h2 class={noStyle ? '' : styles.title}>{copy.title}</h2>
223
+ <p class={noStyle ? '' : styles.description}>
224
+ {descriptionBefore}
225
+ <button
226
+ class={noStyle ? '' : styles.partnersLink}
227
+ data-c15t-action="customize"
228
+ data-c15t-dialog="iab"
229
+ data-c15t-tab="vendors"
230
+ data-testid="iab-consent-banner-partners-link"
231
+ type="button"
232
+ >
233
+ {partnersLinkText}
234
+ </button>
235
+ {descriptionAfter}
236
+ </p>
237
+ <ul class={noStyle ? '' : styles.purposeList}>
238
+ {summary.displayItems.map((name) => (
239
+ <li>{name}</li>
240
+ ))}
241
+ {summary.remainingCount > 0 && (
242
+ <li class={noStyle ? '' : styles.purposeMore}>
243
+ {copy.andMore.replace(
244
+ '{count}',
245
+ String(summary.remainingCount)
246
+ )}
247
+ </li>
248
+ )}
249
+ </ul>
250
+ <p class={noStyle ? '' : styles.legitimateInterestNotice}>
251
+ {`${copy.legitimateInterestNotice} ${copy.scopeServiceSpecific}`}
252
+ </p>
253
+ </div>
254
+ <div
255
+ class={[noStyle ? '' : actionStyles.actionRoot, noStyle ? '' : styles.footer]
256
+ .filter(Boolean)
257
+ .join(' ')}
258
+ data-direction="row"
259
+ data-split="true"
260
+ data-testid="iab-consent-banner-footer"
261
+ >
262
+ <div
263
+ class={noStyle ? '' : actionStyles.actionGroup}
264
+ data-direction="row"
265
+ >
266
+ {(['reject', 'accept'] as const).map((action) => (
267
+ <button
268
+ class={noStyle ? '' : buttonStyles.button}
269
+ data-action={action}
270
+ data-c15t-action={action}
271
+ data-mode={noStyle ? undefined : buttonAttrs(action).mode}
272
+ data-size={noStyle ? undefined : 'small'}
273
+ data-testid={`iab-consent-banner-${action}-button`}
274
+ data-variant={
275
+ noStyle ? undefined : buttonAttrs(action).variant
276
+ }
277
+ type="button"
278
+ >
279
+ {labels[action]}
280
+ </button>
281
+ ))}
282
+ </div>
283
+ <div
284
+ class={noStyle ? '' : actionStyles.actionGroup}
285
+ data-direction="row"
286
+ >
287
+ <button
288
+ class={noStyle ? '' : buttonStyles.button}
289
+ data-action="customize"
290
+ data-c15t-action="customize"
291
+ data-c15t-dialog="iab"
292
+ data-mode={noStyle ? undefined : buttonAttrs('customize').mode}
293
+ data-size={noStyle ? undefined : 'small'}
294
+ data-testid="iab-consent-banner-customize-button"
295
+ data-variant={
296
+ noStyle ? undefined : buttonAttrs('customize').variant
297
+ }
298
+ type="button"
299
+ >
300
+ {labels.customize}
301
+ </button>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ )
308
+ }
309
+
310
+ <script>
311
+ // One delegated listener, shared with `<ConsentBanner />` and every
312
+ // dialog trigger on the site. Astro bundles and dedupes it across pages.
313
+ import { attachBannerActions } from '@c15t/astro/client';
314
+
315
+ attachBannerActions();
316
+ </script>
@@ -0,0 +1,15 @@
1
+ <!--
2
+ The IAB TCF preference centre, on its own chunk.
3
+
4
+ The TCF surface is the biggest thing either dialog can render — vendor
5
+ lists, purposes, stacks — and only a site that configured IAB ever shows
6
+ it. Keeping it in its own island means the preference-centre chunk that
7
+ every other site downloads does not carry it.
8
+ -->
9
+ <script lang="ts">
10
+ import { IABConsentDialog } from '@c15t/svelte';
11
+
12
+ let { tab }: { tab?: 'purposes' | 'vendors' } = $props();
13
+ </script>
14
+
15
+ <IABConsentDialog initialTab={tab} />
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The IAB TCF preference centre, on its own chunk.
3
+ *
4
+ * The TCF surface is the biggest thing either dialog can render — vendor
5
+ * lists, purposes, stacks — and only a site that configured IAB ever shows
6
+ * it. Keeping it in its own island means the preference-centre chunk that
7
+ * every other site downloads does not carry it.
8
+ */
9
+
10
+ import { ConsentDraftProvider } from '@c15t/react';
11
+ import { IABConsentDialog } from '@c15t/react/iab';
12
+
13
+ /** Props the dialog island passes down. */
14
+ export interface IABDialogSurfaceProps {
15
+ /** Which preference-centre tab to open on. */
16
+ tab?: 'purposes' | 'vendors';
17
+ }
18
+
19
+ const IABDialogSurface = ({ tab }: IABDialogSurfaceProps) => (
20
+ <ConsentDraftProvider>
21
+ <IABConsentDialog initialTab={tab} />
22
+ </ConsentDraftProvider>
23
+ );
24
+
25
+ export default IABDialogSurface;
@@ -0,0 +1,20 @@
1
+ <!--
2
+ The IAB TCF preference centre, on its own chunk.
3
+
4
+ The TCF surface is the biggest thing either dialog can render — vendor
5
+ lists, purposes, stacks — and only a site that configured IAB ever shows
6
+ it. Keeping it in its own island means the preference-centre chunk that
7
+ every other site downloads does not carry it.
8
+ -->
9
+ <script setup lang="ts">
10
+ import IABConsentDialog from '@c15t/vue/runtime/components/iab-consent-dialog.vue';
11
+
12
+ const props = defineProps<{
13
+ /** Which preference-centre tab to open on. */
14
+ tab?: 'purposes' | 'vendors';
15
+ }>();
16
+ </script>
17
+
18
+ <template>
19
+ <IABConsentDialog :initial-tab="props.tab" />
20
+ </template>
@@ -0,0 +1,57 @@
1
+ <!--
2
+ The on-demand dialog island.
3
+
4
+ Mounted with Svelte 5's `mount()` the first time something opens a
5
+ dialog — never with `client:load` — so a visitor who never opens the
6
+ preference centre downloads no framework code at all.
7
+
8
+ The provider renders against the page's runtime rather than building one
9
+ of its own: Astro islands cannot see each other's context, so the kernel
10
+ has to be owned outside the component tree. The provider borrows it and
11
+ leaves `start()`/`dispose()` to the owner.
12
+ -->
13
+ <script lang="ts">
14
+ import type { ConsentRuntime } from '@c15t/core/runtime';
15
+ import { ConsentDialog, ConsentManagerProvider } from '@c15t/svelte';
16
+ import type { Component } from 'svelte';
17
+
18
+ let {
19
+ options,
20
+ runtime,
21
+ kind = 'preferences',
22
+ tab,
23
+ }: {
24
+ options: Record<string, unknown>;
25
+ runtime: ConsentRuntime;
26
+ kind?: 'preferences' | 'iab';
27
+ /** Which IAB preference-centre tab to open on. */
28
+ tab?: 'purposes' | 'vendors';
29
+ } = $props();
30
+
31
+ // The TCF surface is the larger half of this island and only an IAB site
32
+ // ever opens it, so it arrives on its own chunk.
33
+ let IABDialog = $state<Component | null>(null);
34
+
35
+ $effect(() => {
36
+ if (kind !== 'iab' || IABDialog) {
37
+ return;
38
+ }
39
+ void (async () => {
40
+ const module = await import('./iab-dialog-surface.svelte');
41
+ IABDialog = module.default as Component;
42
+ })();
43
+ });
44
+ </script>
45
+
46
+ <ConsentManagerProvider
47
+ {runtime}
48
+ options={options as never}
49
+ >
50
+ {#if kind === 'iab'}
51
+ {#if IABDialog}
52
+ <IABDialog {tab} />
53
+ {/if}
54
+ {:else}
55
+ <ConsentDialog />
56
+ {/if}
57
+ </ConsentManagerProvider>
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The on-demand React dialog island.
3
+ *
4
+ * Mounted with `createRoot()` the first time something opens a dialog —
5
+ * never with `client:load` — so a visitor who never opens the preference
6
+ * centre downloads no React from us at all.
7
+ *
8
+ * The provider renders against the page's runtime rather than building one
9
+ * of its own: Astro islands cannot see each other's context, so the kernel
10
+ * has to be owned outside the component tree. `<ConsentProvider runtime>`
11
+ * borrows it and leaves `start()`/`dispose()` to the owner.
12
+ *
13
+ * `<ConsentDraftProvider>` is what makes Save work. `useConsentDraft()`
14
+ * falls back to a draft per hook call when no provider is in scope, so the
15
+ * category toggles and the Save button would each stage into their own
16
+ * copy: the switch would flip, and the save would commit nothing.
17
+ */
18
+
19
+ import type { ConsentRuntime } from '@c15t/core/runtime';
20
+ import {
21
+ ConsentDialog,
22
+ ConsentDraftProvider,
23
+ ConsentProvider,
24
+ } from '@c15t/react';
25
+ import { lazy, Suspense } from 'react';
26
+
27
+ import type { DialogPresentationOptions } from '../../ui/provider-props';
28
+
29
+ // The TCF surface is the larger half of this island and only an IAB site
30
+ // ever opens it, so it arrives on its own chunk.
31
+ const IABDialogSurface = lazy(() => import('./iab-dialog-surface'));
32
+
33
+ /** Props the React dialog adapter passes in. */
34
+ export interface ConsentDialogSurfaceProps {
35
+ /** The page-level runtime. The provider borrows it, it does not own it. */
36
+ runtime: ConsentRuntime;
37
+ /** Presentation options forwarded to the provider. */
38
+ options: DialogPresentationOptions;
39
+ /** Which dialog to render. */
40
+ kind?: 'preferences' | 'iab';
41
+ /** Which IAB preference-centre tab to open on. */
42
+ tab?: 'purposes' | 'vendors';
43
+ }
44
+
45
+ const ConsentDialogSurface = ({
46
+ runtime,
47
+ options,
48
+ kind = 'preferences',
49
+ tab,
50
+ }: ConsentDialogSurfaceProps) => (
51
+ <ConsentProvider
52
+ runtime={runtime}
53
+ options={options}
54
+ >
55
+ {kind === 'iab' ? (
56
+ <Suspense fallback={null}>
57
+ <IABDialogSurface tab={tab} />
58
+ </Suspense>
59
+ ) : (
60
+ <ConsentDraftProvider>
61
+ <ConsentDialog />
62
+ </ConsentDraftProvider>
63
+ )}
64
+ </ConsentProvider>
65
+ );
66
+
67
+ export default ConsentDialogSurface;
@@ -0,0 +1,39 @@
1
+ <!--
2
+ The on-demand Vue dialog island.
3
+
4
+ Mounted as its own Vue app the first time something opens a dialog —
5
+ never with `client:load` — so a visitor who never opens the preference
6
+ centre downloads no consent UI at all.
7
+
8
+ The adapter installs `c15tVue` with the page's runtime before mounting
9
+ this component: Astro islands cannot see each other's provides, so the
10
+ kernel has to be owned outside the app. The plugin borrows it and leaves
11
+ `start()`/`dispose()` to the owner.
12
+ -->
13
+ <script setup lang="ts">
14
+ import ConsentManager from '@c15t/vue/runtime/components/consent-manager.vue';
15
+ import { defineAsyncComponent } from 'vue';
16
+
17
+ withDefaults(
18
+ defineProps<{
19
+ kind?: 'preferences' | 'iab';
20
+ /** Which IAB preference-centre tab to open on. */
21
+ tab?: 'purposes' | 'vendors';
22
+ }>(),
23
+ { kind: 'preferences', tab: undefined }
24
+ );
25
+
26
+ // The TCF surface is the larger half of this island and only an IAB site
27
+ // ever opens it, so it arrives on its own chunk.
28
+ const IABDialogSurface = defineAsyncComponent(
29
+ () => import('./iab-dialog-surface.vue')
30
+ );
31
+ </script>
32
+
33
+ <template>
34
+ <IABDialogSurface
35
+ v-if="kind === 'iab'"
36
+ :tab="tab"
37
+ />
38
+ <ConsentManager v-else />
39
+ </template>
@@ -0,0 +1,48 @@
1
+ ---
2
+ /**
3
+ * A button that opens a consent dialog.
4
+ *
5
+ * Ships no JavaScript of its own — it carries `data-c15t-action` and the
6
+ * one delegated listener the banner already installed picks it up. Use it
7
+ * for the "Cookie preferences" link every footer needs.
8
+ *
9
+ * ```astro
10
+ * <ConsentDialogTrigger>Cookie preferences</ConsentDialogTrigger>
11
+ * <ConsentDialogTrigger kind="iab">Ad partners</ConsentDialogTrigger>
12
+ * ```
13
+ */
14
+ interface Props {
15
+ /** Which dialog to open. */
16
+ kind?: 'preferences' | 'iab';
17
+ /** Extra class names. */
18
+ class?: string;
19
+ /** Render an `<a href="#">` instead of a `<button>`. */
20
+ as?: 'button' | 'a';
21
+ }
22
+
23
+ const { kind = 'preferences', class: className, as = 'button' } = Astro.props;
24
+ const Tag = as;
25
+ // Hidden until a resolved rule owes consent UI: nothing to manage before a
26
+ // rule resolves, and nothing owed under a `none` rule with no rights. The
27
+ // browser client toggles `hidden` as the snapshot changes.
28
+ const hasConsentUi = Astro.locals.c15t?.hasConsentUi ?? true;
29
+ ---
30
+
31
+ <Tag
32
+ class={className}
33
+ data-c15t-action="customize"
34
+ data-c15t-dialog={kind}
35
+ data-c15t-surface="trigger"
36
+ data-testid="consent-dialog-trigger"
37
+ hidden={hasConsentUi ? undefined : true}
38
+ href={as === 'a' ? '#c15t-preferences' : undefined}
39
+ type={as === 'button' ? 'button' : undefined}
40
+ >
41
+ <slot />
42
+ </Tag>
43
+
44
+ <script>
45
+ import { attachBannerActions } from '@c15t/astro/client';
46
+
47
+ attachBannerActions();
48
+ </script>