@gradial/aci 0.1.40 → 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 (95) hide show
  1. package/dist/astro/index.d.ts +1 -1
  2. package/dist/astro/index.js +1 -1
  3. package/dist/astro/page.d.ts +6 -0
  4. package/dist/astro/page.js +3 -2
  5. package/dist/cli/compile-registry.d.mts +2 -0
  6. package/dist/cli/compile-registry.mjs +759 -0
  7. package/dist/cli/materialize-template.d.mts +19 -0
  8. package/dist/cli/materialize-template.mjs +128 -0
  9. package/dist/cli/validate-content.d.mts +2 -0
  10. package/dist/cli/validate-content.mjs +426 -0
  11. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  12. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  13. package/dist/collect/assignments.d.ts +65 -0
  14. package/dist/collect/assignments.js +138 -0
  15. package/dist/collect/batcher.d.ts +21 -0
  16. package/dist/collect/batcher.js +10 -0
  17. package/dist/collect/declarative.d.ts +1 -1
  18. package/dist/collect/declarative.js +7 -1
  19. package/dist/collect/index.js +30 -4
  20. package/dist/collect/pageviews.d.ts +15 -3
  21. package/dist/collect/pageviews.js +28 -13
  22. package/dist/collect/transport.d.ts +2 -0
  23. package/dist/collect/transport.js +32 -0
  24. package/dist/content/personalization.d.ts +35 -0
  25. package/dist/content/personalization.js +57 -0
  26. package/dist/content/types.d.ts +7 -1
  27. package/dist/edge/personalization-core.d.ts +11 -0
  28. package/dist/edge/personalization-core.js +265 -0
  29. package/dist/next/assignments-script.d.ts +14 -0
  30. package/dist/next/assignments-script.js +17 -0
  31. package/dist/next/collect-route.d.ts +19 -0
  32. package/dist/next/collect-route.js +351 -0
  33. package/dist/next/index.d.ts +1 -0
  34. package/dist/next/index.js +1 -0
  35. package/dist/next/middleware.d.ts +15 -1
  36. package/dist/next/middleware.js +71 -27
  37. package/dist/next/page.js +2 -1
  38. package/dist/next/personalization-host.d.ts +118 -0
  39. package/dist/next/personalization-host.js +276 -0
  40. package/dist/next/server.js +6 -6
  41. package/dist/next/vercel-personalization.d.ts +3 -0
  42. package/dist/next/vercel-personalization.js +3 -0
  43. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  44. package/dist/next/vercel-s3-personalization.js +220 -0
  45. package/dist/providers/s3.d.ts +1 -1
  46. package/dist/providers/s3.js +1 -1
  47. package/dist/react/VideoPlayer.d.ts +4 -4
  48. package/dist/react/index.d.ts +4 -4
  49. package/dist/types/page.d.ts +1 -1
  50. package/package.json +24 -16
  51. package/src/cli/validate-content.mjs +0 -0
  52. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  53. package/src/types/page.ts +1 -1
  54. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  55. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  56. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  57. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  58. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  59. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  60. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  61. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  62. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  63. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  64. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  65. package/templates/common/.content/pages/home/_index.json +114 -4
  66. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  67. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  68. package/templates/common/contracts/components/index.ts +21 -1
  69. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  70. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  71. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  72. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  73. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  74. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  75. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  76. package/templates/nextjs/template/src/middleware.ts +6 -2
  77. package/templates/astro/template/.aci.yaml +0 -28
  78. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  79. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  80. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  81. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  82. package/templates/common/.content/config/robots.txt +0 -2
  83. package/templates/common/.content/config/site.json +0 -197
  84. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  85. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  86. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  87. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  88. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  89. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  90. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  91. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  92. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  93. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  94. package/templates/common/.content/pages/about/_index.json +0 -51
  95. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,3 @@
1
+ export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, resolvePersonalizationRequest, } from './personalization-host.js';
2
+ export { createPersonalizationCollectRoute, } from './collect-route.js';
3
+ export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, VercelS3PersonalizationStore, } from './vercel-s3-personalization.js';
@@ -0,0 +1,58 @@
1
+ import type { EdgeRouteManifest, PersonalizationCollectionStore, PersonalizationHostConfig, PersonalizationRouteStore } from './personalization-host.js';
2
+ export interface VercelS3PersonalizationConfig {
3
+ region: string;
4
+ roleArn?: string;
5
+ hostingBucket: string;
6
+ eventsBucket: string;
7
+ hostingKeyPrefix?: string;
8
+ claimsKeyPrefix?: string;
9
+ endpoint?: string;
10
+ forcePathStyle?: boolean;
11
+ }
12
+ export interface S3CommandClient {
13
+ send(command: {
14
+ input: unknown;
15
+ }): Promise<any>;
16
+ }
17
+ /**
18
+ * Node-only Vercel collection adapter for audience lake objects and signed
19
+ * claims. The EdgeRouter never receives this adapter or AWS credentials.
20
+ * Production construction exchanges the Vercel workload OIDC token for
21
+ * scoped AWS credentials; no long-lived AWS keys are read.
22
+ */
23
+ export declare class VercelS3PersonalizationStore implements PersonalizationCollectionStore {
24
+ private readonly config;
25
+ private readonly client;
26
+ constructor(config: VercelS3PersonalizationConfig, client?: S3CommandClient);
27
+ readClaims(siteId: string, namespace: string, identityHash: string): Promise<string | null>;
28
+ appendEvent(key: string, record: unknown): Promise<void>;
29
+ putSuppression(key: string, record: unknown): Promise<void>;
30
+ private readObject;
31
+ }
32
+ /**
33
+ * Edge Config is the provider projection for immutable release routing state.
34
+ * This tiny store is safe in Next middleware: it only fetches the route
35
+ * projection and never reads S3, claims, or event data.
36
+ */
37
+ export declare class VercelEdgeConfigRouteStore implements PersonalizationRouteStore {
38
+ private readonly edgeConfig;
39
+ private readonly siteId;
40
+ constructor(edgeConfig: string, siteId: string);
41
+ readEdgeManifest(releaseId: string): Promise<EdgeRouteManifest | null>;
42
+ }
43
+ export declare function createVercelS3PersonalizationStore(config: VercelS3PersonalizationConfig): VercelS3PersonalizationStore;
44
+ /**
45
+ * Builds the EdgeRouter configuration used by Next middleware. It deliberately
46
+ * has no AWS/S3 configuration: its only remote dependency is the provider's
47
+ * Edge Config projection.
48
+ */
49
+ export declare function createVercelPersonalizationFromEnv(env?: Record<string, string | undefined>): PersonalizationHostConfig | undefined;
50
+ /** Builds the Node.js `/collect` configuration. This is the only Vercel
51
+ * application runtime that receives data-plane storage configuration. */
52
+ export declare function createVercelCollectionFromEnv(env?: Record<string, string | undefined>): {
53
+ siteId: string;
54
+ store: PersonalizationCollectionStore;
55
+ identityKeys: PersonalizationHostConfig['identityKeys'];
56
+ claimsKeys: Record<string, string>;
57
+ secureCookies: boolean;
58
+ } | undefined;
@@ -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
  */
@@ -20,8 +20,12 @@ export declare function VideoPlayer({ video, ...attrs }: VideoPlayerProps): impo
20
20
  style?: import("react").CSSProperties | undefined;
21
21
  dir?: string | undefined | undefined;
22
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;
@@ -29,8 +29,12 @@ export declare function Image({ image, value, ...attrs }: ImageProps): import("r
29
29
  style?: import("react").CSSProperties | undefined;
30
30
  dir?: string | undefined | undefined;
31
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.40",
3
+ "version": "0.1.42",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -67,6 +67,11 @@
67
67
  "import": "./dist/next/middleware.js",
68
68
  "default": "./dist/next/middleware.js"
69
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
+ },
70
75
  "./next/asset-route": {
71
76
  "types": "./dist/next/asset-route.d.ts",
72
77
  "import": "./dist/next/asset-route.js",
@@ -98,15 +103,9 @@
98
103
  "import": "./dist/testing/conformance/index.js"
99
104
  }
100
105
  },
101
- "scripts": {
102
- "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
103
- "test": "node --import tsx --test \"src/**/*.test.ts\"",
104
- "build:cli": "cd ../.. && ./scripts/build-cli.sh",
105
- "generate-starters": "cd ../.. && tsx scripts/generate-starters.ts",
106
- "compile-registry": "tsx src/cli/compile-registry.mjs",
107
- "prepack": "npm run generate-starters && npm run build && node scripts/ensure-bin-modes.mjs"
108
- },
109
106
  "dependencies": {
107
+ "@aws-sdk/client-s3": "^3.883.0",
108
+ "@vercel/oidc-aws-credentials-provider": "^3.3.0",
110
109
  "markdown-it": "^14.1.0",
111
110
  "sanitize-html": "^2.13.1",
112
111
  "ws": "^8.18.0"
@@ -138,6 +137,7 @@
138
137
  "@types/react": "^19.0.0",
139
138
  "@types/sanitize-html": "^2.16.0",
140
139
  "@types/ws": "^8.18.0",
140
+ "esbuild": "0.28.1",
141
141
  "jsdom": "^29.1.1",
142
142
  "next": "^15.5.19",
143
143
  "tsx": "^4.20.0",
@@ -145,11 +145,19 @@
145
145
  "zod": "^4.0.0"
146
146
  },
147
147
  "optionalDependencies": {
148
- "@gradial/aci-cli-darwin-arm64": "0.1.40",
149
- "@gradial/aci-cli-darwin-x64": "0.1.40",
150
- "@gradial/aci-cli-linux-arm64": "0.1.40",
151
- "@gradial/aci-cli-linux-x64": "0.1.40",
152
- "@gradial/aci-cli-win32-arm64": "0.1.40",
153
- "@gradial/aci-cli-win32-x64": "0.1.40"
148
+ "@gradial/aci-cli-darwin-arm64": "0.1.41",
149
+ "@gradial/aci-cli-darwin-x64": "0.1.41",
150
+ "@gradial/aci-cli-linux-arm64": "0.1.41",
151
+ "@gradial/aci-cli-linux-x64": "0.1.41",
152
+ "@gradial/aci-cli-win32-arm64": "0.1.41",
153
+ "@gradial/aci-cli-win32-x64": "0.1.41"
154
+ },
155
+ "scripts": {
156
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
157
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
158
+ "build:collect": "node scripts/build-collect.mjs",
159
+ "build:cli": "cd ../.. && ./scripts/build-cli.sh",
160
+ "generate-starters": "cd ../.. && tsx scripts/generate-starters.ts",
161
+ "compile-registry": "tsx src/cli/compile-registry.mjs"
154
162
  }
155
- }
163
+ }
File without changes
@@ -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(
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],
@@ -0,0 +1,41 @@
1
+ ---
2
+ import type { ContentProps } from '../../cms/contracts/components/personalizationChoice.contract';
3
+
4
+ type Props = ContentProps;
5
+
6
+ const { title, description, redLabel, blueLabel, refreshLabel } = Astro.props;
7
+ ---
8
+
9
+ <section class="border-y border-[var(--color-border)] bg-[var(--color-surface)] py-[var(--spacing-section-y)]">
10
+ <div class="mx-auto max-w-[var(--layout-content-max)] px-[var(--layout-gutter)] text-center">
11
+ <h2 class="text-[length:var(--text-h2)] font-[var(--font-weight-semibold)] text-[var(--color-fg)]">{title}</h2>
12
+ {description ? <p class="mx-auto mt-3 max-w-2xl text-[var(--color-fg-muted)]">{description}</p> : null}
13
+ <div class="mt-6 flex flex-wrap justify-center gap-3">
14
+ <button type="button" class="inline-flex min-h-10 items-center justify-center rounded-button border border-emerald-700 bg-emerald-600 px-[var(--spacing-button-x)] py-[var(--spacing-button-y)] font-[var(--font-weight-semibold)] text-white transition hover:bg-emerald-700" data-aci-consent-button>Enable personalization</button>
15
+ <button type="button" class="inline-flex min-h-10 items-center justify-center rounded-button border border-red-700 bg-red-600 px-[var(--spacing-button-x)] py-[var(--spacing-button-y)] font-[var(--font-weight-semibold)] text-white transition hover:bg-red-700" data-aci-event="preference" data-aci-prop-color="red">{redLabel}</button>
16
+ <button type="button" class="inline-flex min-h-10 items-center justify-center rounded-button border border-blue-700 bg-blue-600 px-[var(--spacing-button-x)] py-[var(--spacing-button-y)] font-[var(--font-weight-semibold)] text-white transition hover:bg-blue-700" data-aci-event="preference" data-aci-prop-color="blue">{blueLabel}</button>
17
+ <button type="button" class="inline-flex min-h-10 items-center justify-center rounded-button border border-[var(--button-outline-border)] bg-[var(--button-outline-bg)] px-[var(--spacing-button-x)] py-[var(--spacing-button-y)] font-[var(--font-weight-semibold)] text-[var(--button-outline-text)] transition hover:opacity-85" data-aci-refresh>{refreshLabel}</button>
18
+ </div>
19
+ <p class="mx-auto mt-4 max-w-2xl text-sm text-[var(--color-fg-muted)]" data-aci-cookie-status aria-live="polite">Consent: checking... | Browser marker: checking... | Identity and audience cookies: HttpOnly server-managed</p>
20
+ </div>
21
+ </section>
22
+
23
+ <script is:inline>
24
+ (() => {
25
+ const hasCookie = (name) => document.cookie.split('; ').some((value) => value.startsWith(`${name}=`));
26
+ const status = document.querySelector('[data-aci-cookie-status]');
27
+ const render = () => {
28
+ if (!status) return;
29
+ status.textContent = `Consent: ${hasCookie('aci_consent') ? 'granted' : 'not granted'} | Browser marker: ${hasCookie('aci_m') ? 'minted' : 'not minted'} | Identity and audience cookies: HttpOnly server-managed`;
30
+ };
31
+ document.querySelector('[data-aci-consent-button]')?.addEventListener('click', () => {
32
+ const envelope = encodeURIComponent(JSON.stringify({ v: 1, p: ['personalization'], t: new Date().toISOString(), src: 'demo-consent' }));
33
+ document.cookie = `aci_consent=${envelope}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`;
34
+ window.aci?.('consent', { p: ['personalization'], src: 'demo-consent' });
35
+ render();
36
+ window.setTimeout(() => document.querySelector('[data-aci-refresh]')?.click(), 50);
37
+ }, true);
38
+ render();
39
+ window.setInterval(render, 1000);
40
+ })();
41
+ </script>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import SiteLayout from '../layouts/SiteLayout.astro';
3
3
  import type { KernelPage, KernelSiteConfig } from '@gradial/aci/content';
4
- import { registryLookup } from '@gradial/aci/astro';
4
+ import { registryLookup, collectScriptTagHTML } from '@gradial/aci/astro';
5
5
  import { registry } from '../cms/registry';
6
6
  import Header from '../components/chrome/Header.astro';
7
7
  import Footer from '../components/chrome/Footer.astro';
@@ -24,12 +24,13 @@ const title = page.metadata?.title || siteConfig.seo?.title || siteConfig.title;
24
24
  const description = page.metadata?.description || siteConfig.seo?.description || '';
25
25
  const canonical = page.metadata?.canonical;
26
26
  const siteName = siteConfig.seo?.siteName || siteConfig.title;
27
+ const collectTag = collectScriptTagHTML(siteConfig);
27
28
 
28
29
  const footer = siteConfig.footer || {};
29
30
  const blocks = page?.regions?.main || [];
30
31
  ---
31
32
 
32
- <SiteLayout {title} {description} {canonical} {siteName}>
33
+ <SiteLayout {title} {description} {canonical} {siteName} {collectTag}>
33
34
  <Header
34
35
  brandHref={siteConfig.brandHref || '/'}
35
36
  brandLabel={siteConfig.title || 'Gradial'}
@@ -14,9 +14,10 @@ interface Props {
14
14
  * content).
15
15
  */
16
16
  collectTag?: string;
17
+ assignmentsTag?: string;
17
18
  }
18
19
 
19
- const { title, description, canonical, siteName = 'Gradial', collectTag } = Astro.props;
20
+ const { title, description, canonical, siteName = 'Gradial', collectTag, assignmentsTag } = Astro.props;
20
21
  ---
21
22
 
22
23
  <!doctype html>
@@ -32,6 +33,7 @@ const { title, description, canonical, siteName = 'Gradial', collectTag } = Astr
32
33
  <meta property="og:description" content={description} />
33
34
  <meta property="og:site_name" content={siteName} />
34
35
  {collectTag ? <Fragment set:html={collectTag} /> : null}
36
+ {assignmentsTag ? <Fragment set:html={assignmentsTag} /> : null}
35
37
  </head>
36
38
  <body>
37
39
  <slot />
@@ -17,6 +17,7 @@ const renderChildren = createRenderChildren(registry);
17
17
  canonical={data.meta.canonical}
18
18
  siteName={data.meta.siteName}
19
19
  collectTag={data.collectTag}
20
+ assignmentsTag={data.assignmentsTag}
20
21
  >
21
22
  {data.layout.slots.map((slot) => {
22
23
  const blocks = data.slots[slot.name];
@@ -0,0 +1,11 @@
1
+ {
2
+ "$type": "audience",
3
+ "props": {
4
+ "id": "preference-blue",
5
+ "displayName": "Blue preference",
6
+ "status": "enabled",
7
+ "priority": 100,
8
+ "membershipSource": "segment",
9
+ "segmentRef": "$ref:segments/preference-blue"
10
+ }
11
+ }