@gradial/aci 0.1.28 → 0.1.30

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 (73) hide show
  1. package/README.md +47 -0
  2. package/bin/aci-darwin-arm64 +0 -0
  3. package/dist/astro/index.d.ts +1 -0
  4. package/dist/astro/index.js +7 -0
  5. package/dist/collect/api.d.ts +24 -0
  6. package/dist/collect/api.js +63 -0
  7. package/dist/collect/batcher.d.ts +65 -0
  8. package/dist/collect/batcher.js +127 -0
  9. package/dist/collect/config.d.ts +30 -0
  10. package/dist/collect/config.js +53 -0
  11. package/dist/collect/consent/adapters/c15t.d.ts +2 -0
  12. package/dist/collect/consent/adapters/c15t.js +43 -0
  13. package/dist/collect/consent/adapters/cookiebot.d.ts +2 -0
  14. package/dist/collect/consent/adapters/cookiebot.js +48 -0
  15. package/dist/collect/consent/adapters/onetrust.d.ts +2 -0
  16. package/dist/collect/consent/adapters/onetrust.js +54 -0
  17. package/dist/collect/consent/adapters/trustarc.d.ts +2 -0
  18. package/dist/collect/consent/adapters/trustarc.js +65 -0
  19. package/dist/collect/consent/envelope.d.ts +49 -0
  20. package/dist/collect/consent/envelope.js +100 -0
  21. package/dist/collect/consent/mapping.d.ts +62 -0
  22. package/dist/collect/consent/mapping.js +129 -0
  23. package/dist/collect/declarative.d.ts +35 -0
  24. package/dist/collect/declarative.js +100 -0
  25. package/dist/collect/gapbuffer.d.ts +55 -0
  26. package/dist/collect/gapbuffer.js +134 -0
  27. package/dist/collect/index.d.ts +6 -0
  28. package/dist/collect/index.js +154 -0
  29. package/dist/collect/linkclicks.d.ts +12 -0
  30. package/dist/collect/linkclicks.js +35 -0
  31. package/dist/collect/pageviews.d.ts +22 -0
  32. package/dist/collect/pageviews.js +42 -0
  33. package/dist/collect/transport.d.ts +22 -0
  34. package/dist/collect/transport.js +52 -0
  35. package/dist/collect/ulid.d.ts +6 -0
  36. package/dist/collect/ulid.js +36 -0
  37. package/dist/collect/variationviews.d.ts +31 -0
  38. package/dist/collect/variationviews.js +80 -0
  39. package/dist/collect/webvitals.d.ts +38 -0
  40. package/dist/collect/webvitals.js +99 -0
  41. package/dist/content/index.d.ts +1 -0
  42. package/dist/content/index.js +1 -0
  43. package/dist/content/personalization.d.ts +57 -0
  44. package/dist/content/personalization.js +54 -0
  45. package/dist/content/types.d.ts +3 -0
  46. package/dist/next/collect-script.d.ts +12 -0
  47. package/dist/next/collect-script.js +15 -0
  48. package/dist/next/config.js +1 -0
  49. package/dist/next/index.d.ts +1 -0
  50. package/dist/next/index.js +1 -0
  51. package/dist/next/page.d.ts +2 -0
  52. package/dist/next/page.js +5 -2
  53. package/dist/providers/file.js +1 -8
  54. package/dist/react/VideoPlayer.d.ts +3 -3
  55. package/dist/react/index.d.ts +4 -4
  56. package/dist/react/index.js +1 -1
  57. package/dist/types/image.d.ts +27 -13
  58. package/dist/types/image.js +17 -17
  59. package/dist/types/media.d.ts +10 -4
  60. package/dist/types/video.d.ts +5 -2
  61. package/package.json +8 -9
  62. package/src/cli/validate-content.mjs +0 -0
  63. package/src/types/image.ts +32 -29
  64. package/templates/astro/template/src/fixtures/sections.ts +1 -1
  65. package/templates/astro/template/src/layouts/SiteLayout.astro +77 -1
  66. package/templates/astro/template/src/pages/[...slug].astro +3 -2
  67. package/templates/common/.content/config/site.json +5 -0
  68. package/templates/common/.content/schemas/site.schema.json +15 -0
  69. package/templates/common/README.md.tmpl +15 -0
  70. package/templates/nextjs/template/package.json.tmpl +2 -0
  71. package/templates/nextjs/template/src/app/_components/ConsentManager.tsx +132 -0
  72. package/templates/nextjs/template/src/app/layout.tsx +3 -1
  73. package/templates/nextjs/template/src/components/sections/HomeHero.tsx +1 -1
@@ -1,19 +1,25 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // Image source variant (responsive derivative)
4
+ // Image source variants
5
5
  //
6
- // Each entry is one concrete derivative produced by the compiler for the asset
7
- // slot. Renderers pick the primary variant and build a srcset from the list.
6
+ // Each source provides a primary src and may include a ready-made srcset
7
+ // scoped by the optional media/sizes of the slot output that produced it.
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  export interface ImageSource {
11
- /** Derivative URL. */
11
+ /** Primary derivative URL for this rendition candidate. */
12
12
  src: string;
13
- /** Intrinsic width in pixels. */
14
- width: number;
15
- /** Intrinsic height in pixels. */
16
- height: number;
13
+ /** Intrinsic width in pixels (0 when the format carries none, e.g. SVG). */
14
+ width?: number;
15
+ /** Intrinsic height in pixels (0 when the format carries none). */
16
+ height?: number;
17
+ /** Optional width-descriptor rendition candidates. */
18
+ srcset?: string;
19
+ /** sizes attribute from the slot contract. */
20
+ sizes?: string;
21
+ /** Media query scoping this source (art direction). */
22
+ media?: string;
17
23
  /** MIME type hint, e.g. image/webp. */
18
24
  type: string;
19
25
  }
@@ -35,7 +41,7 @@ export interface Image {
35
41
  versionId: string;
36
42
  /** Accessible description of the image content. */
37
43
  alt: string;
38
- /** Responsive derivative variants produced for the asset slot. */
44
+ /** <source> candidates produced for the asset slot. */
39
45
  sources: ImageSource[];
40
46
  }
41
47
 
@@ -61,8 +67,11 @@ export interface SlotOutput {
61
67
 
62
68
  export const ImageSourceSchema = z.object({
63
69
  src: z.string().min(1),
64
- width: z.number().int().nonnegative(),
65
- height: z.number().int().nonnegative(),
70
+ srcset: z.string().min(1).optional(),
71
+ width: z.number().int().nonnegative().optional(),
72
+ height: z.number().int().nonnegative().optional(),
73
+ sizes: z.string().min(1).optional(),
74
+ media: z.string().min(1).optional(),
66
75
  type: z.string().min(1),
67
76
  });
68
77
 
@@ -81,38 +90,32 @@ export const ImageSchema = z.object({
81
90
  export type ImageAttrs = Record<string, unknown>;
82
91
 
83
92
  /**
84
- * Picks the primary variant from an image's sources.
85
- * Uses the largest width; ties fall back to the first entry.
93
+ * The primary rendition of an image — its first source entry.
86
94
  */
87
95
  export function primaryImageSource(image: Image): ImageSource {
88
- return image.sources.reduce((largest, source) =>
89
- source.width > largest.width ? source : largest,
90
- image.sources[0],
91
- );
96
+ return image.sources[0];
92
97
  }
93
98
 
94
99
  /**
95
100
  * Computes the full set of <img> attributes for an Image.
96
101
  *
97
102
  * Framework components (React <Picture>, Astro <img>, Svelte <img>) call this
98
- * and spread the result into their native element. No HTML string serialization.
103
+ * and spread the result into their native element. No HTML string
104
+ * serialization. Media-scoped sources need a <picture> wrapper and are not
105
+ * representable as plain <img> attributes, so srcset/sizes are taken from the
106
+ * first unscoped source only.
99
107
  */
100
108
  export function imageAttrs(image: Image, overrides: ImageAttrs = {}): ImageAttrs {
101
- const primary = primaryImageSource(image);
102
- const srcset = image.sources
103
- .map((s) => `${s.src} ${s.width}w`)
104
- .join(', ');
105
- const sizes = image.sources
106
- .map((s) => `(max-width: ${s.width}px) ${s.width}px`)
107
- .join(', ');
109
+ const primary = image.sources[0];
110
+ const unscoped = image.sources.find((s) => !s.media) ?? primary;
108
111
 
109
112
  return {
110
113
  src: primary.src,
111
114
  alt: image.alt,
112
- width: primary.width > 0 ? primary.width : undefined,
113
- height: primary.height > 0 ? primary.height : undefined,
114
- srcSet: srcset || undefined,
115
- sizes: sizes || undefined,
115
+ width: primary.width && primary.width > 0 ? primary.width : undefined,
116
+ height: primary.height && primary.height > 0 ? primary.height : undefined,
117
+ srcSet: unscoped?.srcset || undefined,
118
+ sizes: unscoped?.sizes || undefined,
116
119
  loading: 'lazy',
117
120
  decoding: 'async',
118
121
  ...overrides,
@@ -28,7 +28,7 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
28
28
  versionId: 'v0001',
29
29
  alt: 'Seattle skyline at dusk',
30
30
  sources: [
31
- { src: '/assets/seattle.avif', width: 1920, height: 1280, type: 'image/avif' }
31
+ { src: '/assets/seattle.avif', width: 1920, height: 1280, sizes: '100vw', type: 'image/avif' }
32
32
  ]
33
33
  }
34
34
  }
@@ -6,9 +6,17 @@ interface Props {
6
6
  description: string;
7
7
  canonical?: string;
8
8
  siteName?: string;
9
+ /**
10
+ * Pre-rendered Gradial collect script tag (collectScriptTagHTML from
11
+ * '@gradial/aci/astro'). Empty string / undefined = personalization
12
+ * disabled, no tag. Emitted here because the Astro integration has no
13
+ * attribute-bearing HTML-injection seam (injectScript only injects script
14
+ * content).
15
+ */
16
+ collectTag?: string;
9
17
  }
10
18
 
11
- const { title, description, canonical, siteName = 'Gradial' } = Astro.props;
19
+ const { title, description, canonical, siteName = 'Gradial', collectTag } = Astro.props;
12
20
  ---
13
21
 
14
22
  <!doctype html>
@@ -23,8 +31,76 @@ const { title, description, canonical, siteName = 'Gradial' } = Astro.props;
23
31
  <meta property="og:title" content={title} />
24
32
  <meta property="og:description" content={description} />
25
33
  <meta property="og:site_name" content={siteName} />
34
+ {collectTag ? <Fragment set:html={collectTag} /> : null}
26
35
  </head>
27
36
  <body>
28
37
  <slot />
38
+ <script>
39
+ // Gradial collect ↔ c15t bridge. The collect SDK's c15t preset
40
+ // (data-aci-consent="c15t" on the collect tag) reads
41
+ // window.c15t = { consents, onConsentChange }. c15t itself publishes
42
+ // its zustand store as window.c15tStore instead, so this bridge
43
+ // mirrors any c15t store on the page into the shape the SDK reads.
44
+ //
45
+ // c15t has no Astro UI package — add a consent banner via a React
46
+ // island (@astrojs/react + @c15t/react) or any setup built on
47
+ // c15t's createConsentManagerStore; this bridge picks it up either
48
+ // way. Without a c15t setup the poll gives up silently and only the
49
+ // JS consent API (aci('consent', …) under the manual preset) writes
50
+ // envelopes.
51
+ //
52
+ // Consents are mirrored only after a real visitor decision
53
+ // (consentInfo set), never for pre-decision defaults, so the SDK
54
+ // cannot mistake an untouched banner for a withdrawal.
55
+ type Consents = Record<string, boolean>;
56
+ type ConsentListener = (payload: { consents: Consents }) => void;
57
+ interface ConsentStoreState {
58
+ consents?: Consents;
59
+ consentInfo?: unknown;
60
+ }
61
+ interface ConsentStoreLike {
62
+ getState(): ConsentStoreState;
63
+ subscribe(listener: (state: ConsentStoreState) => void): () => void;
64
+ }
65
+
66
+ const listeners: ConsentListener[] = [];
67
+
68
+ function publish(consents: Consents): void {
69
+ const w = window as typeof window & {
70
+ c15t?: { consents?: Consents; onConsentChange?: (cb: ConsentListener) => void };
71
+ };
72
+ const bridge = (w.c15t ??= {});
73
+ bridge.consents = consents;
74
+ bridge.onConsentChange ??= (cb: ConsentListener) => {
75
+ listeners.push(cb);
76
+ };
77
+ for (const listener of [...listeners]) {
78
+ listener({ consents });
79
+ }
80
+ }
81
+
82
+ function onState(state: ConsentStoreState): void {
83
+ if (state.consentInfo && state.consents) publish(state.consents);
84
+ }
85
+
86
+ function connect(store: ConsentStoreLike): void {
87
+ onState(store.getState());
88
+ store.subscribe(onState);
89
+ }
90
+
91
+ // Bounded poll for the store global — same schedule the collect SDK
92
+ // uses for window.c15t (250 ms for up to 10 s, then give up silently).
93
+ let remaining = 40;
94
+ function poll(): void {
95
+ const store = (window as { c15tStore?: ConsentStoreLike }).c15tStore;
96
+ if (store) {
97
+ connect(store);
98
+ return;
99
+ }
100
+ remaining -= 1;
101
+ if (remaining > 0) setTimeout(poll, 250);
102
+ }
103
+ poll();
104
+ </script>
29
105
  </body>
30
106
  </html>
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { getGradialStaticPaths, getPageRuntimeRenderInput, createRenderChildren } from '@gradial/aci/astro';
2
+ import { getGradialStaticPaths, getPageRuntimeRenderInput, createRenderChildren, collectScriptTagHTML } from '@gradial/aci/astro';
3
3
  import { normalizeRoute, routeMetadataForContent, resolveSlots } from '@gradial/aci/content';
4
4
  import { FileContentProvider } from '@gradial/aci/providers/file';
5
5
  import type { KernelPage, KernelSiteConfig, LayoutEntry, FragmentData } from '@gradial/aci/content';
@@ -36,9 +36,10 @@ const resolvedSlots = await resolveSlots(
36
36
  );
37
37
 
38
38
  const renderChildren = createRenderChildren(registry);
39
+ const collectTag = collectScriptTagHTML(siteConfig);
39
40
  ---
40
41
 
41
- <SiteLayout title={meta.title} description={meta.description} canonical={meta.canonical} siteName={meta.siteName}>
42
+ <SiteLayout title={meta.title} description={meta.description} canonical={meta.canonical} siteName={meta.siteName} collectTag={collectTag}>
42
43
  {layout.slots.map((slot) => {
43
44
  const blocks = resolvedSlots[slot.name];
44
45
  if (!blocks?.length) return null;
@@ -17,6 +17,11 @@
17
17
  "description": "Global satellite internet from anywhere on Earth.",
18
18
  "siteName": "Meridian"
19
19
  },
20
+ "personalization": {
21
+ "collect": true,
22
+ "consentPreset": "c15t",
23
+ "consentCategories": "measurement,marketing"
24
+ },
20
25
  "navigation": [
21
26
  {
22
27
  "id": "home",
@@ -76,6 +76,21 @@
76
76
  "description": { "type": "string" },
77
77
  "siteName": { "type": "string" }
78
78
  }
79
+ },
80
+ "personalization": {
81
+ "type": "object",
82
+ "additionalProperties": true,
83
+ "properties": {
84
+ "collect": { "type": "boolean" },
85
+ "consentPreset": {
86
+ "enum": ["c15t", "onetrust", "cookiebot", "trustarc", "manual"]
87
+ },
88
+ "consentCategories": {
89
+ "type": "string",
90
+ "pattern": "^\\s*[A-Za-z0-9._-]+(\\s*,\\s*[A-Za-z0-9._-]+)*\\s*$"
91
+ },
92
+ "debug": { "type": "boolean" }
93
+ }
79
94
  }
80
95
  },
81
96
  "$defs": {
@@ -43,3 +43,18 @@ src/design-system/ Design tokens and global styles
43
43
  .agents/skills/ Skills for humans and agents
44
44
  .aci/ Generated artifacts (compiled, schemas)
45
45
  ```
46
+
47
+ ## Personalization
48
+
49
+ The `personalization` block in `.content/config/site.json` controls the
50
+ Gradial collect tag (`<script defer src="/_aci/aci.js" …>`); pages emit it
51
+ automatically when `collect` is `true`, and not at all when the block is
52
+ absent. Consent is bridged from c15t (`consentPreset: "c15t"`): the Next kit
53
+ ships a full c15t setup (`src/app/_components/ConsentManager.tsx`, offline
54
+ mode by default, hosted via `NEXT_PUBLIC_C15T_BACKEND_URL`); the Astro kit
55
+ ships the store bridge in `SiteLayout.astro` — add a banner via a React
56
+ island (`@astrojs/react` + `@c15t/react`) and it is picked up automatically.
57
+
58
+ Personalization requires the deployed site to be served through a Gradial
59
+ edge binding, which provides `/collect`, identity, and the SDK itself. See
60
+ the `@gradial/aci` README ("Personalization: The One-Tag Install").
@@ -17,7 +17,9 @@
17
17
  "build:storybook": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
+ "@c15t/nextjs": "^2.1.0",
20
21
  "@gradial/aci": "{{aciVersion}}",
22
+ "js-yaml": "^4.1.1",
21
23
  "next": "^15.1.6",
22
24
  "react": "^19.0.0",
23
25
  "react-dom": "^19.0.0"
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ import {
4
+ ConsentBanner,
5
+ ConsentDialog,
6
+ ConsentManagerProvider,
7
+ useConsentManager,
8
+ type ConsentManagerProviderProps,
9
+ } from '@c15t/nextjs';
10
+ import { useEffect, useMemo, type ReactNode } from 'react';
11
+
12
+ // Hosted c15t backend (consent.io or self-hosted). Unset = offline mode:
13
+ // consent is stored client-side and no network calls are made.
14
+ const C15T_BACKEND_URL = process.env.NEXT_PUBLIC_C15T_BACKEND_URL;
15
+
16
+ // The CMP's own categories. The mapping from categories to the Gradial
17
+ // personalization purpose lives in .content/config/site.json
18
+ // (personalization.consentCategories), not here.
19
+ const CONSENT_CATEGORIES = ['necessary', 'measurement', 'marketing'] as const;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Gradial collect bridge
23
+ //
24
+ // The Gradial collect SDK's c15t preset (the site config's
25
+ // `personalization.consentPreset: "c15t"`, emitted as
26
+ // `data-aci-consent="c15t"` on the collect tag) reads
27
+ // `window.c15t = { consents, onConsentChange }`. c15t itself does not
28
+ // publish that shape, so this bridge mirrors the store onto it — only after
29
+ // a real visitor decision (`consentInfo` set), never for pre-decision
30
+ // defaults, so the SDK cannot mistake an untouched banner for a withdrawal.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ type Consents = Record<string, boolean>;
34
+ type ConsentListener = (payload: { consents: Consents }) => void;
35
+
36
+ declare global {
37
+ interface Window {
38
+ c15t?: {
39
+ consents?: Consents;
40
+ onConsentChange?: (cb: ConsentListener) => void;
41
+ };
42
+ }
43
+ }
44
+
45
+ const consentListeners: ConsentListener[] = [];
46
+
47
+ function publishAciConsents(consents: Consents): void {
48
+ const bridge = (window.c15t ??= {});
49
+ bridge.consents = consents;
50
+ bridge.onConsentChange ??= (cb: ConsentListener) => {
51
+ consentListeners.push(cb);
52
+ };
53
+ for (const listener of [...consentListeners]) {
54
+ listener({ consents });
55
+ }
56
+ }
57
+
58
+ function AciConsentBridge() {
59
+ const { consents, consentInfo } = useConsentManager();
60
+ useEffect(() => {
61
+ if (!consentInfo || !consents) return;
62
+ publishAciConsents(consents as Consents);
63
+ }, [consents, consentInfo]);
64
+ return null;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Consent manager
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function getConsentOptions(): ConsentManagerProviderProps['options'] {
72
+ const baseOptions = {
73
+ consentCategories: [...CONSENT_CATEGORIES],
74
+ legalLinks: {
75
+ privacyPolicy: {
76
+ href: '/privacy/',
77
+ target: '_self' as const,
78
+ label: 'Privacy policy',
79
+ },
80
+ },
81
+ };
82
+
83
+ if (C15T_BACKEND_URL) {
84
+ return {
85
+ ...baseOptions,
86
+ mode: 'hosted',
87
+ backendURL: C15T_BACKEND_URL,
88
+ };
89
+ }
90
+
91
+ return {
92
+ ...baseOptions,
93
+ mode: 'offline',
94
+ offlinePolicy: {
95
+ policyPacks: [
96
+ {
97
+ id: 'default-opt-in',
98
+ match: { isDefault: true },
99
+ consent: {
100
+ model: 'opt-in',
101
+ categories: [...CONSENT_CATEGORIES],
102
+ },
103
+ ui: { mode: 'banner' },
104
+ },
105
+ ],
106
+ },
107
+ };
108
+ }
109
+
110
+ export function ConsentManager({ children }: { children: ReactNode }) {
111
+ const options = useMemo(() => getConsentOptions(), []);
112
+
113
+ return (
114
+ <ConsentManagerProvider options={options}>
115
+ {children}
116
+ <AciConsentBridge />
117
+ <ConsentBanner
118
+ title="Cookie preferences"
119
+ description="We use cookies to keep the site working, measure performance, and support marketing."
120
+ acceptButtonText="Accept all"
121
+ rejectButtonText="Reject all"
122
+ customizeButtonText="Customize"
123
+ legalLinks={['privacyPolicy']}
124
+ layout={[['reject', 'accept'], 'customize']}
125
+ primaryButton="accept"
126
+ scrollLock={false}
127
+ hideBranding
128
+ />
129
+ <ConsentDialog legalLinks={['privacyPolicy']} scrollLock trapFocus hideBranding />
130
+ </ConsentManagerProvider>
131
+ );
132
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { Inter, Space_Grotesk } from 'next/font/google';
3
3
  import { DevRefresh } from '@gradial/aci/next';
4
+ import { ConsentManager } from '@/app/_components/ConsentManager';
5
+ import '@c15t/nextjs/styles.css';
4
6
  import '@/design-system/styles.css';
5
7
 
6
8
  const inter = Inter({
@@ -21,7 +23,7 @@ export default function Layout({ children }: { children: ReactNode }) {
21
23
  return (
22
24
  <html lang="en" className={`${inter.variable} ${spaceGrotesk.variable}`}>
23
25
  <body>
24
- {children}
26
+ <ConsentManager>{children}</ConsentManager>
25
27
  <DevRefresh />
26
28
  </body>
27
29
  </html>
@@ -57,6 +57,6 @@ export function HomeHero({ eyebrow = '', headline = '', description = '', ctaLab
57
57
  }
58
58
 
59
59
  function heroImageSrc(image?: HeroImage) {
60
- if (!image || image.sources.length === 0) return '';
60
+ if (!image) return '';
61
61
  return primaryImageSource(image).src;
62
62
  }