@gradial/aci 0.1.41 → 0.1.42

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 (102) hide show
  1. package/CHANGELOG.md +9 -13
  2. package/README.md +4 -4
  3. package/dist/astro/index.d.ts +1 -1
  4. package/dist/astro/index.js +1 -1
  5. package/dist/astro/page.d.ts +6 -0
  6. package/dist/astro/page.js +3 -2
  7. package/dist/cli/compile-registry.d.mts +2 -0
  8. package/dist/cli/compile-registry.mjs +759 -0
  9. package/dist/cli/materialize-template.d.mts +19 -0
  10. package/dist/cli/materialize-template.mjs +128 -0
  11. package/dist/cli/validate-content.d.mts +2 -0
  12. package/dist/cli/validate-content.mjs +426 -0
  13. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  14. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  15. package/dist/collect/assignments.d.ts +65 -0
  16. package/dist/collect/assignments.js +138 -0
  17. package/dist/collect/batcher.d.ts +21 -0
  18. package/dist/collect/batcher.js +10 -0
  19. package/dist/collect/declarative.d.ts +1 -1
  20. package/dist/collect/declarative.js +7 -1
  21. package/dist/collect/index.js +30 -4
  22. package/dist/collect/pageviews.d.ts +15 -3
  23. package/dist/collect/pageviews.js +28 -13
  24. package/dist/collect/transport.d.ts +2 -0
  25. package/dist/collect/transport.js +32 -0
  26. package/dist/consent-banner/index.d.ts +33 -0
  27. package/dist/consent-banner/index.js +99 -0
  28. package/dist/content/personalization.d.ts +35 -0
  29. package/dist/content/personalization.js +57 -0
  30. package/dist/content/types.d.ts +7 -1
  31. package/dist/edge/personalization-core.d.ts +11 -0
  32. package/dist/edge/personalization-core.js +265 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/next/assignments-script.d.ts +14 -0
  36. package/dist/next/assignments-script.js +17 -0
  37. package/dist/next/collect-route.d.ts +19 -0
  38. package/dist/next/collect-route.js +351 -0
  39. package/dist/next/index.d.ts +1 -0
  40. package/dist/next/index.js +1 -0
  41. package/dist/next/middleware.d.ts +15 -1
  42. package/dist/next/middleware.js +71 -27
  43. package/dist/next/page.js +2 -1
  44. package/dist/next/personalization-host.d.ts +118 -0
  45. package/dist/next/personalization-host.js +276 -0
  46. package/dist/next/server.js +6 -6
  47. package/dist/next/vercel-personalization.d.ts +3 -0
  48. package/dist/next/vercel-personalization.js +3 -0
  49. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  50. package/dist/next/vercel-s3-personalization.js +220 -0
  51. package/dist/providers/s3.d.ts +1 -1
  52. package/dist/providers/s3.js +1 -1
  53. package/dist/react/VideoPlayer.d.ts +6 -6
  54. package/dist/react/index.d.ts +6 -6
  55. package/dist/types/page.d.ts +1 -1
  56. package/package.json +19 -1
  57. package/src/astro/ConsentBanner.astro +62 -0
  58. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  59. package/src/consent-banner/styles.css +110 -0
  60. package/src/types/page.ts +1 -1
  61. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  62. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  63. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  64. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  65. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  66. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  67. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  68. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  69. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  70. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  71. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  72. package/templates/common/.content/pages/home/_index.json +114 -4
  73. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  74. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  75. package/templates/common/contracts/components/index.ts +21 -1
  76. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  77. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  78. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  79. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  80. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  81. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  82. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  83. package/templates/nextjs/template/src/middleware.ts +6 -2
  84. package/templates/astro/template/.aci.yaml +0 -28
  85. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  86. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  87. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  88. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  89. package/templates/common/.content/config/robots.txt +0 -2
  90. package/templates/common/.content/config/site.json +0 -197
  91. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  92. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  93. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  94. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  95. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  96. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  97. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  98. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  99. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  100. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  101. package/templates/common/.content/pages/about/_index.json +0 -51
  102. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,220 @@
1
+ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
2
+ import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
3
+ import { getUncachedEdgeConfigValue } from './edge-config.js';
4
+ /**
5
+ * Node-only Vercel collection adapter for audience lake objects and signed
6
+ * claims. The EdgeRouter never receives this adapter or AWS credentials.
7
+ * Production construction exchanges the Vercel workload OIDC token for
8
+ * scoped AWS credentials; no long-lived AWS keys are read.
9
+ */
10
+ export class VercelS3PersonalizationStore {
11
+ config;
12
+ client;
13
+ constructor(config, client) {
14
+ this.config = config;
15
+ const s3Config = {
16
+ region: config.region,
17
+ endpoint: config.endpoint,
18
+ forcePathStyle: config.forcePathStyle,
19
+ ...(config.roleArn ? { credentials: awsCredentialsProvider({
20
+ roleArn: config.roleArn,
21
+ clientConfig: { region: config.region },
22
+ }) } : {}),
23
+ };
24
+ this.client = client || new S3Client(s3Config);
25
+ }
26
+ async readClaims(siteId, namespace, identityHash) {
27
+ const key = joinKey(this.config.claimsKeyPrefix || 'audience/claims/v1', requiredSegment(identityHash, 'identity hash').slice(0, 2).toLowerCase(), requiredSegment(siteId, 'site id'), requiredSegment(namespace, 'identity namespace').toLowerCase(), `${requiredSegment(identityHash, 'identity hash').toLowerCase()}.json`);
28
+ const body = await this.readObject(this.config.eventsBucket, key);
29
+ if (body === null)
30
+ return null;
31
+ const envelope = JSON.parse(body);
32
+ if (envelope.schemaVersion !== 1 || !envelope.signedValue?.trim()) {
33
+ throw new Error(`invalid claims envelope at ${key}`);
34
+ }
35
+ if (envelope.expiresAt && Date.parse(envelope.expiresAt) <= Date.now())
36
+ return null;
37
+ return envelope.signedValue;
38
+ }
39
+ async appendEvent(key, record) {
40
+ const input = {
41
+ Bucket: this.config.eventsBucket,
42
+ Key: cleanKey(key),
43
+ Body: JSON.stringify(record),
44
+ ContentType: 'application/json',
45
+ IfNoneMatch: '*',
46
+ };
47
+ try {
48
+ await this.client.send(new PutObjectCommand(input));
49
+ }
50
+ catch (error) {
51
+ if (!isConditionalConflict(error))
52
+ throw error;
53
+ // S3 can report 409 for two concurrent conditional writes even when
54
+ // neither write has committed yet. Confirm the object before treating
55
+ // the conflict as the idempotent duplicate case.
56
+ try {
57
+ await this.client.send(new HeadObjectCommand({ Bucket: input.Bucket, Key: input.Key }));
58
+ }
59
+ catch (headError) {
60
+ throw new Error(`conditional event write conflicted before ${input.Key} was stored`, { cause: headError });
61
+ }
62
+ }
63
+ }
64
+ async putSuppression(key, record) {
65
+ await this.client.send(new PutObjectCommand({
66
+ Bucket: this.config.eventsBucket,
67
+ Key: cleanKey(key),
68
+ Body: JSON.stringify(record),
69
+ ContentType: 'application/json',
70
+ }));
71
+ }
72
+ async readObject(bucket, key) {
73
+ try {
74
+ const output = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
75
+ if (!output.Body)
76
+ throw new Error(`empty object body at ${key}`);
77
+ if (typeof output.Body.transformToString === 'function')
78
+ return await output.Body.transformToString();
79
+ throw new Error(`unsupported object body at ${key}`);
80
+ }
81
+ catch (error) {
82
+ if (isNotFound(error))
83
+ return null;
84
+ throw error;
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Edge Config is the provider projection for immutable release routing state.
90
+ * This tiny store is safe in Next middleware: it only fetches the route
91
+ * projection and never reads S3, claims, or event data.
92
+ */
93
+ export class VercelEdgeConfigRouteStore {
94
+ edgeConfig;
95
+ siteId;
96
+ constructor(edgeConfig, siteId) {
97
+ this.edgeConfig = edgeConfig;
98
+ this.siteId = siteId;
99
+ }
100
+ async readEdgeManifest(releaseId) {
101
+ const value = await getUncachedEdgeConfigValue(this.edgeConfig, edgeRoutesKey(this.siteId, releaseId));
102
+ if (!value)
103
+ return null;
104
+ if (typeof value === 'string')
105
+ return JSON.parse(value);
106
+ return value;
107
+ }
108
+ }
109
+ export function createVercelS3PersonalizationStore(config) {
110
+ return new VercelS3PersonalizationStore(config);
111
+ }
112
+ /**
113
+ * Builds the EdgeRouter configuration used by Next middleware. It deliberately
114
+ * has no AWS/S3 configuration: its only remote dependency is the provider's
115
+ * Edge Config projection.
116
+ */
117
+ export function createVercelPersonalizationFromEnv(env = process.env) {
118
+ if (env.ACI_PERSONALIZATION_HOST_ENABLED !== 'true')
119
+ return undefined;
120
+ const siteId = requiredEnv(env, 'ACI_SITE_ID');
121
+ const edgeConfig = requiredEnv(env, 'EDGE_CONFIG');
122
+ const currentKID = env.ACI_IDENTITY_KID || env.AUDIENCE_IDENTITY_COOKIE_KID || 'v1';
123
+ const currentSecret = env.ACI_IDENTITY_SECRET || env.AUDIENCE_IDENTITY_COOKIE_SECRET || '';
124
+ if (!currentSecret)
125
+ throw new Error('ACI_IDENTITY_SECRET is required when ACI_PERSONALIZATION_HOST_ENABLED=true');
126
+ const previousKID = env.ACI_IDENTITY_KID_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_KID_PREVIOUS || '';
127
+ const previousSecret = env.ACI_IDENTITY_SECRET_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_SECRET_PREVIOUS || '';
128
+ if ((previousKID === '') !== (previousSecret === '')) {
129
+ throw new Error('previous identity kid and secret must be configured together');
130
+ }
131
+ const identityKeys = {
132
+ current: { kid: currentKID, secret: currentSecret },
133
+ ...(previousKID ? { previous: { kid: previousKID, secret: previousSecret } } : {}),
134
+ };
135
+ return {
136
+ siteId,
137
+ store: new VercelEdgeConfigRouteStore(edgeConfig, siteId),
138
+ identityKeys,
139
+ claimsKeys: {
140
+ [currentKID]: currentSecret,
141
+ ...(previousKID ? { [previousKID]: previousSecret } : {}),
142
+ },
143
+ secureCookies: env.AUDIENCE_IDENTITY_COOKIE_SECURE !== 'false',
144
+ };
145
+ }
146
+ /** Builds the Node.js `/collect` configuration. This is the only Vercel
147
+ * application runtime that receives data-plane storage configuration. */
148
+ export function createVercelCollectionFromEnv(env = process.env) {
149
+ if (env.ACI_PERSONALIZATION_HOST_ENABLED !== 'true')
150
+ return undefined;
151
+ const siteId = requiredEnv(env, 'ACI_SITE_ID');
152
+ const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || '';
153
+ if (!region)
154
+ throw new Error('AWS_REGION is required for the Vercel /collect route');
155
+ const currentKID = env.ACI_IDENTITY_KID || env.AUDIENCE_IDENTITY_COOKIE_KID || 'v1';
156
+ const currentSecret = env.ACI_IDENTITY_SECRET || env.AUDIENCE_IDENTITY_COOKIE_SECRET || '';
157
+ if (!currentSecret)
158
+ throw new Error('ACI_IDENTITY_SECRET is required for the Vercel /collect route');
159
+ const previousKID = env.ACI_IDENTITY_KID_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_KID_PREVIOUS || '';
160
+ const previousSecret = env.ACI_IDENTITY_SECRET_PREVIOUS || env.AUDIENCE_IDENTITY_COOKIE_SECRET_PREVIOUS || '';
161
+ if ((previousKID === '') !== (previousSecret === '')) {
162
+ throw new Error('previous identity kid and secret must be configured together');
163
+ }
164
+ const identityKeys = {
165
+ current: { kid: currentKID, secret: currentSecret },
166
+ ...(previousKID ? { previous: { kid: previousKID, secret: previousSecret } } : {}),
167
+ };
168
+ return {
169
+ siteId,
170
+ store: createVercelS3PersonalizationStore({
171
+ region,
172
+ roleArn: env.AWS_ROLE_ARN?.trim() || undefined,
173
+ hostingBucket: requiredEnv(env, 'HOSTING_BUCKET'),
174
+ eventsBucket: requiredEnv(env, 'EVENTS_BUCKET'),
175
+ hostingKeyPrefix: env.HOSTING_KEY_PREFIX,
176
+ claimsKeyPrefix: env.AUDIENCE_CLAIMS_S3_PREFIX,
177
+ }),
178
+ identityKeys,
179
+ claimsKeys: {
180
+ [currentKID]: currentSecret,
181
+ ...(previousKID ? { [previousKID]: previousSecret } : {}),
182
+ },
183
+ secureCookies: env.AUDIENCE_IDENTITY_COOKIE_SECURE !== 'false',
184
+ };
185
+ }
186
+ function requiredSegment(value, label) {
187
+ const clean = cleanKey(value);
188
+ if (!clean || clean.includes('/'))
189
+ throw new Error(`${label} is required and must be one path segment`);
190
+ return clean;
191
+ }
192
+ function requiredEnv(env, name) {
193
+ const value = env[name]?.trim() || '';
194
+ if (!value)
195
+ throw new Error(`${name} is required when ACI_PERSONALIZATION_HOST_ENABLED=true`);
196
+ return value;
197
+ }
198
+ function cleanKey(value) {
199
+ return (value || '').trim().replace(/^\/+|\/+$/g, '');
200
+ }
201
+ function joinKey(...parts) {
202
+ return parts.map(cleanKey).filter(Boolean).join('/');
203
+ }
204
+ function edgeRoutesKey(siteId, releaseId) {
205
+ return `edgeRoutes_${edgeKeySegment(siteId)}_${edgeKeySegment(releaseId)}`;
206
+ }
207
+ function edgeKeySegment(value) {
208
+ return value.replace(/[^A-Za-z0-9_-]/g, '_');
209
+ }
210
+ function isNotFound(error) {
211
+ const value = error;
212
+ return value?.$metadata?.httpStatusCode === 404 || value?.name === 'NoSuchKey' || value?.name === 'NotFound';
213
+ }
214
+ function isConditionalConflict(error) {
215
+ const value = error;
216
+ return value?.$metadata?.httpStatusCode === 409
217
+ || value?.$metadata?.httpStatusCode === 412
218
+ || value?.name === 'ConditionalRequestConflict'
219
+ || value?.name === 'PreconditionFailed';
220
+ }
@@ -18,7 +18,7 @@ export declare class S3ContentProvider implements ContentProvider {
18
18
  getPage<T = unknown>(route: string): Promise<T>;
19
19
  /**
20
20
  * getPageByRef serves the exact payload the edge selected (the
21
- * X-Baremetal-Function-Ref contract): base page or variant. Only refs the
21
+ * X-Aci-Content-Ref contract): base page or variant. Only refs the
22
22
  * release manifest declares are served — the header names published
23
23
  * content, it cannot address arbitrary keys.
24
24
  */
@@ -34,7 +34,7 @@ export class S3ContentProvider {
34
34
  }
35
35
  /**
36
36
  * getPageByRef serves the exact payload the edge selected (the
37
- * X-Baremetal-Function-Ref contract): base page or variant. Only refs the
37
+ * X-Aci-Content-Ref contract): base page or variant. Only refs the
38
38
  * release manifest declares are served — the header names published
39
39
  * content, it cannot address arbitrary keys.
40
40
  */
@@ -16,12 +16,16 @@ export declare function VideoPlayer({ video, ...attrs }: VideoPlayerProps): impo
16
16
  title: string;
17
17
  rel?: string | undefined | undefined;
18
18
  content?: string | undefined | undefined;
19
- dir?: string | undefined | undefined;
20
- hidden?: boolean | undefined | undefined;
21
19
  slot?: string | undefined | undefined;
22
20
  style?: import("react").CSSProperties | undefined;
21
+ dir?: string | undefined | undefined;
22
+ hidden?: boolean | undefined | undefined;
23
+ dangerouslySetInnerHTML?: {
24
+ __html: string | TrustedHTML;
25
+ } | undefined | undefined;
23
26
  children?: import("react").ReactNode;
24
27
  className?: string | undefined | undefined;
28
+ lang?: string | undefined | undefined;
25
29
  crossOrigin?: "" | "anonymous" | "use-credentials" | undefined;
26
30
  defaultChecked?: boolean | undefined | undefined;
27
31
  defaultValue?: string | number | readonly string[] | undefined;
@@ -34,7 +38,6 @@ export declare function VideoPlayer({ video, ...attrs }: VideoPlayerProps): impo
34
38
  contextMenu?: string | undefined | undefined;
35
39
  draggable?: (boolean | "true" | "false") | undefined;
36
40
  enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
37
- lang?: string | undefined | undefined;
38
41
  nonce?: string | undefined | undefined;
39
42
  spellCheck?: (boolean | "true" | "false") | undefined;
40
43
  tabIndex?: number | undefined | undefined;
@@ -121,9 +124,6 @@ export declare function VideoPlayer({ video, ...attrs }: VideoPlayerProps): impo
121
124
  "aria-valuemin"?: number | undefined | undefined;
122
125
  "aria-valuenow"?: number | undefined | undefined;
123
126
  "aria-valuetext"?: string | undefined | undefined;
124
- dangerouslySetInnerHTML?: {
125
- __html: string | TrustedHTML;
126
- } | undefined | undefined;
127
127
  onCopy?: import("react").ClipboardEventHandler<HTMLVideoElement> | undefined;
128
128
  onCopyCapture?: import("react").ClipboardEventHandler<HTMLVideoElement> | undefined;
129
129
  onCut?: import("react").ClipboardEventHandler<HTMLVideoElement> | undefined;
@@ -25,12 +25,16 @@ export declare function Image({ image, value, ...attrs }: ImageProps): import("r
25
25
  rel?: string | undefined | undefined;
26
26
  content?: string | undefined | undefined;
27
27
  referrerPolicy?: import("react").HTMLAttributeReferrerPolicy | undefined;
28
- dir?: string | undefined | undefined;
29
- hidden?: boolean | undefined | undefined;
30
28
  slot?: string | undefined | undefined;
31
29
  style?: import("react").CSSProperties | undefined;
30
+ dir?: string | undefined | undefined;
31
+ hidden?: boolean | undefined | undefined;
32
+ dangerouslySetInnerHTML?: {
33
+ __html: string | TrustedHTML;
34
+ } | undefined | undefined;
32
35
  children?: ReactNode;
33
36
  className?: string | undefined | undefined;
37
+ lang?: string | undefined | undefined;
34
38
  crossOrigin?: "" | "anonymous" | "use-credentials" | undefined;
35
39
  fetchPriority?: "high" | "low" | "auto" | undefined | undefined;
36
40
  useMap?: string | undefined | undefined;
@@ -45,7 +49,6 @@ export declare function Image({ image, value, ...attrs }: ImageProps): import("r
45
49
  contextMenu?: string | undefined | undefined;
46
50
  draggable?: (boolean | "true" | "false") | undefined;
47
51
  enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined | undefined;
48
- lang?: string | undefined | undefined;
49
52
  nonce?: string | undefined | undefined;
50
53
  spellCheck?: (boolean | "true" | "false") | undefined;
51
54
  tabIndex?: number | undefined | undefined;
@@ -132,9 +135,6 @@ export declare function Image({ image, value, ...attrs }: ImageProps): import("r
132
135
  "aria-valuemin"?: number | undefined | undefined;
133
136
  "aria-valuenow"?: number | undefined | undefined;
134
137
  "aria-valuetext"?: string | undefined | undefined;
135
- dangerouslySetInnerHTML?: {
136
- __html: string | TrustedHTML;
137
- } | undefined | undefined;
138
138
  onCopy?: import("react").ClipboardEventHandler<HTMLImageElement> | undefined;
139
139
  onCopyCapture?: import("react").ClipboardEventHandler<HTMLImageElement> | undefined;
140
140
  onCut?: import("react").ClipboardEventHandler<HTMLImageElement> | undefined;
@@ -30,7 +30,7 @@ export interface BlockNode {
30
30
  component: string;
31
31
  props: Record<string, unknown>;
32
32
  renderHints: BlockRenderHints;
33
- contentRef?: string;
33
+ contentPath?: string;
34
34
  }
35
35
  export interface BlockRenderHints {
36
36
  canStatic: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -9,6 +9,8 @@
9
9
  "bin/aci.js",
10
10
  "src/cli/*.mjs",
11
11
  "src/types/*.ts",
12
+ "src/astro/*.astro",
13
+ "src/consent-banner/*.css",
12
14
  "templates/common",
13
15
  "templates/nextjs",
14
16
  "templates/astro",
@@ -65,6 +67,11 @@
65
67
  "import": "./dist/next/middleware.js",
66
68
  "default": "./dist/next/middleware.js"
67
69
  },
70
+ "./next/vercel-personalization": {
71
+ "types": "./dist/next/vercel-personalization.d.ts",
72
+ "import": "./dist/next/vercel-personalization.js",
73
+ "default": "./dist/next/vercel-personalization.js"
74
+ },
68
75
  "./next/asset-route": {
69
76
  "types": "./dist/next/asset-route.d.ts",
70
77
  "import": "./dist/next/asset-route.js",
@@ -84,12 +91,21 @@
84
91
  "import": "./dist/astro/index.js",
85
92
  "default": "./dist/astro/index.js"
86
93
  },
94
+ "./astro/ConsentBanner": {
95
+ "import": "./src/astro/ConsentBanner.astro"
96
+ },
97
+ "./consent-banner": {
98
+ "types": "./dist/consent-banner/index.d.ts",
99
+ "import": "./dist/consent-banner/index.js"
100
+ },
87
101
  "./testing": {
88
102
  "types": "./dist/testing/conformance/index.d.ts",
89
103
  "import": "./dist/testing/conformance/index.js"
90
104
  }
91
105
  },
92
106
  "dependencies": {
107
+ "@aws-sdk/client-s3": "^3.883.0",
108
+ "@vercel/oidc-aws-credentials-provider": "^3.3.0",
93
109
  "markdown-it": "^14.1.0",
94
110
  "sanitize-html": "^2.13.1",
95
111
  "ws": "^8.18.0"
@@ -121,6 +137,7 @@
121
137
  "@types/react": "^19.0.0",
122
138
  "@types/sanitize-html": "^2.16.0",
123
139
  "@types/ws": "^8.18.0",
140
+ "esbuild": "0.28.1",
124
141
  "jsdom": "^29.1.1",
125
142
  "next": "^15.5.19",
126
143
  "tsx": "^4.20.0",
@@ -138,6 +155,7 @@
138
155
  "scripts": {
139
156
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
140
157
  "test": "node --import tsx --test \"src/**/*.test.ts\"",
158
+ "build:collect": "node scripts/build-collect.mjs",
141
159
  "build:cli": "cd ../.. && ./scripts/build-cli.sh",
142
160
  "generate-starters": "cd ../.. && tsx scripts/generate-starters.ts",
143
161
  "compile-registry": "tsx src/cli/compile-registry.mjs"
@@ -0,0 +1,62 @@
1
+ ---
2
+ // ConsentBanner.astro — Astro wrapper around the SDK's first-party consent
3
+ // banner. Import this into your layout and pass site-specific text/styles.
4
+ //
5
+ // Usage:
6
+ // import ConsentBanner from '@gradial/aci/astro/ConsentBanner.astro';
7
+ // <ConsentBanner
8
+ // heading="Cookie Preferences"
9
+ // message="We use cookies to personalize your experience."
10
+ // acceptLabel="Accept all"
11
+ // rejectLabel="Reject"
12
+ // privacyPolicyHref="/privacy"
13
+ // privacyPolicyLabel="Privacy Policy"
14
+ // />
15
+
16
+ export interface Props {
17
+ heading?: string;
18
+ message?: string;
19
+ acceptLabel?: string;
20
+ rejectLabel?: string;
21
+ privacyPolicyLabel?: string;
22
+ privacyPolicyHref?: string;
23
+ source?: string;
24
+ }
25
+
26
+ const {
27
+ heading,
28
+ message,
29
+ acceptLabel,
30
+ rejectLabel,
31
+ privacyPolicyLabel,
32
+ privacyPolicyHref,
33
+ source,
34
+ } = Astro.props as Props;
35
+
36
+ const opts = JSON.stringify({
37
+ heading,
38
+ message,
39
+ acceptLabel,
40
+ rejectLabel,
41
+ privacyPolicyLabel,
42
+ privacyPolicyHref,
43
+ source,
44
+ });
45
+ ---
46
+
47
+ <div
48
+ id="aci-consent-banner-mount"
49
+ data-aci-consent-banner-mount
50
+ data-opts={opts}
51
+ >
52
+ </div>
53
+
54
+ <script>
55
+ import { mountConsentBanner } from '../consent-banner/index.js';
56
+
57
+ const mount = document.getElementById('aci-consent-banner-mount');
58
+ if (mount) {
59
+ const opts = JSON.parse(mount.getAttribute('data-opts') || '{}');
60
+ mountConsentBanner(document, opts);
61
+ }
62
+ </script>
@@ -52,16 +52,17 @@ checkMissing(
52
52
  'Use src/app/[[...slug]]/page.tsx (optional catch-all). The required [...slug] form breaks file-provider local dev where "/" arrives with an empty slug.',
53
53
  );
54
54
 
55
- checkMissing(
56
- 'legacy app-level asset route is removed',
55
+ checkFile(
56
+ 'canonical asset route exists',
57
57
  'src/app/gradial/assets/[...path]/route.ts',
58
- 'Assets are served via URL rewrites in withAci(), not an app-level route.',
58
+ (content) => content.includes('@gradial/aci/next/asset-route'),
59
+ 'Expected the canonical asset route to use @gradial/aci/next/asset-route.',
59
60
  );
60
61
 
61
62
  checkMissing(
62
63
  'legacy release-scoped asset route is removed',
63
64
  'src/app/gradial/assets/[releaseId]/[...path]/route.ts',
64
- 'Assets are served via URL rewrites in withAci(), not an app-level route.',
65
+ 'Release-scoped asset URLs are produced by withAci() rewrites into the canonical src/app/gradial/assets/[...path]/route.ts; do not add a [releaseId] route.',
65
66
  );
66
67
 
67
68
  checkMissing(
@@ -0,0 +1,110 @@
1
+ /* Base consent banner styles — sites override with their own CSS. */
2
+ /* The banner is injected into document.body by mountConsentBanner(). */
3
+
4
+ #aci-consent-banner {
5
+ position: fixed;
6
+ bottom: 0;
7
+ left: 0;
8
+ right: 0;
9
+ z-index: 9999;
10
+ background: #1a1a2e;
11
+ color: #fff;
12
+ font-family: system-ui, -apple-system, sans-serif;
13
+ font-size: 14px;
14
+ line-height: 1.5;
15
+ transition: opacity 0.3s ease, transform 0.3s ease;
16
+ opacity: 1;
17
+ transform: translateY(0);
18
+ }
19
+
20
+ #aci-consent-banner.aci-consent-banner--hidden {
21
+ opacity: 0;
22
+ transform: translateY(100%);
23
+ pointer-events: none;
24
+ }
25
+
26
+ #aci-consent-banner .aci-consent-banner__content {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ gap: 16px;
31
+ max-width: 1200px;
32
+ margin: 0 auto;
33
+ padding: 16px 24px;
34
+ }
35
+
36
+ #aci-consent-banner .aci-consent-banner__text {
37
+ flex: 1;
38
+ min-width: 0;
39
+ }
40
+
41
+ #aci-consent-banner .aci-consent-banner__heading {
42
+ margin: 0 0 4px 0;
43
+ font-size: 16px;
44
+ font-weight: 600;
45
+ }
46
+
47
+ #aci-consent-banner .aci-consent-banner__message {
48
+ margin: 0;
49
+ font-size: 13px;
50
+ opacity: 0.85;
51
+ }
52
+
53
+ #aci-consent-banner .aci-consent-banner__privacy-link {
54
+ margin-left: 8px;
55
+ color: #8ecae6;
56
+ text-decoration: underline;
57
+ font-size: 13px;
58
+ }
59
+
60
+ #aci-consent-banner .aci-consent-banner__actions {
61
+ display: flex;
62
+ gap: 8px;
63
+ flex-shrink: 0;
64
+ }
65
+
66
+ #aci-consent-banner .aci-consent-banner__btn {
67
+ padding: 8px 20px;
68
+ border: none;
69
+ border-radius: 6px;
70
+ font-size: 14px;
71
+ font-weight: 500;
72
+ cursor: pointer;
73
+ transition: background-color 0.15s ease;
74
+ }
75
+
76
+ #aci-consent-banner .aci-consent-banner__btn--accept {
77
+ background: #2196f3;
78
+ color: #fff;
79
+ }
80
+
81
+ #aci-consent-banner .aci-consent-banner__btn--accept:hover {
82
+ background: #1976d2;
83
+ }
84
+
85
+ #aci-consent-banner .aci-consent-banner__btn--reject {
86
+ background: transparent;
87
+ color: #fff;
88
+ border: 1px solid rgba(255, 255, 255, 0.3);
89
+ }
90
+
91
+ #aci-consent-banner .aci-consent-banner__btn--reject:hover {
92
+ background: rgba(255, 255, 255, 0.1);
93
+ }
94
+
95
+ @media (max-width: 640px) {
96
+ #aci-consent-banner .aci-consent-banner__content {
97
+ flex-direction: column;
98
+ align-items: stretch;
99
+ gap: 12px;
100
+ }
101
+
102
+ #aci-consent-banner .aci-consent-banner__actions {
103
+ flex-direction: column-reverse;
104
+ }
105
+
106
+ #aci-consent-banner .aci-consent-banner__btn {
107
+ width: 100%;
108
+ text-align: center;
109
+ }
110
+ }
package/src/types/page.ts CHANGED
@@ -31,7 +31,7 @@ export interface BlockNode {
31
31
  component: string;
32
32
  props: Record<string, unknown>;
33
33
  renderHints: BlockRenderHints;
34
- contentRef?: string;
34
+ contentPath?: string;
35
35
  }
36
36
 
37
37
  export interface BlockRenderHints {
@@ -3,12 +3,14 @@ import { containerContract } from './container.contract';
3
3
  import { featureCardContract } from './featureCard.contract';
4
4
  import { featureGridContract } from './featureGrid.contract';
5
5
  import { homeHeroContract } from './homeHero.contract';
6
+ import { personalizationChoiceContract } from './personalizationChoice.contract';
6
7
  import { siteNavigationContract } from './siteNavigation.contract';
7
8
  import { siteFooterContract } from './siteFooter.contract';
8
9
  import { statsBarContract } from './statsBar.contract';
9
10
 
10
11
  const componentContracts = [
11
12
  homeHeroContract,
13
+ personalizationChoiceContract,
12
14
  containerContract,
13
15
  buttonContract,
14
16
  siteNavigationContract,
@@ -0,0 +1,17 @@
1
+ import { defineComponentContract } from '@gradial/aci';
2
+ import { z } from 'zod';
3
+
4
+ export const schema = z.object({
5
+ title: z.string().min(1),
6
+ description: z.string().optional(),
7
+ redLabel: z.string().min(1),
8
+ blueLabel: z.string().min(1),
9
+ refreshLabel: z.string().min(1),
10
+ });
11
+
12
+ export type ContentProps = z.infer<typeof schema>;
13
+
14
+ export const personalizationChoiceContract = defineComponentContract({
15
+ id: 'personalization_choice',
16
+ props: schema,
17
+ });
@@ -1,5 +1,6 @@
1
1
  import { createRegistry } from '@gradial/aci';
2
2
  import { homeHeroContract } from './contracts/components/homeHero.contract';
3
+ import { personalizationChoiceContract } from './contracts/components/personalizationChoice.contract';
3
4
  import { containerContract } from './contracts/components/container.contract';
4
5
  import { buttonContract } from './contracts/components/button.contract';
5
6
  import { siteNavigationContract } from './contracts/components/siteNavigation.contract';
@@ -8,6 +9,7 @@ import { featureGridContract } from './contracts/components/featureGrid.contract
8
9
  import { featureCardContract } from './contracts/components/featureCard.contract';
9
10
  import { statsBarContract } from './contracts/components/statsBar.contract';
10
11
  import HomeHero from '../components/sections/HomeHero.astro';
12
+ import PersonalizationChoice from '../components/sections/PersonalizationChoice.astro';
11
13
  import ContainerSection from '../components/sections/ContainerSection.astro';
12
14
  import ButtonBlock from '../components/elements/ButtonBlock.astro';
13
15
  import Header from '../components/chrome/Header.astro';
@@ -18,6 +20,7 @@ import StatsBar from '../components/sections/StatsBar.astro';
18
20
 
19
21
  export const registry = createRegistry([
20
22
  [homeHeroContract, HomeHero],
23
+ [personalizationChoiceContract, PersonalizationChoice],
21
24
  [containerContract, ContainerSection],
22
25
  [buttonContract, ButtonBlock],
23
26
  [siteNavigationContract, Header],