@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.
- package/dist/astro/index.d.ts +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/astro/page.d.ts +6 -0
- package/dist/astro/page.js +3 -2
- package/dist/cli/compile-registry.d.mts +2 -0
- package/dist/cli/compile-registry.mjs +759 -0
- package/dist/cli/materialize-template.d.mts +19 -0
- package/dist/cli/materialize-template.mjs +128 -0
- package/dist/cli/validate-content.d.mts +2 -0
- package/dist/cli/validate-content.mjs +426 -0
- package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
- package/dist/cli/verify-next-cdn-routes.mjs +47 -0
- package/dist/collect/assignments.d.ts +65 -0
- package/dist/collect/assignments.js +138 -0
- package/dist/collect/batcher.d.ts +21 -0
- package/dist/collect/batcher.js +10 -0
- package/dist/collect/declarative.d.ts +1 -1
- package/dist/collect/declarative.js +7 -1
- package/dist/collect/index.js +30 -4
- package/dist/collect/pageviews.d.ts +15 -3
- package/dist/collect/pageviews.js +28 -13
- package/dist/collect/transport.d.ts +2 -0
- package/dist/collect/transport.js +32 -0
- package/dist/content/personalization.d.ts +35 -0
- package/dist/content/personalization.js +57 -0
- package/dist/content/types.d.ts +7 -1
- package/dist/edge/personalization-core.d.ts +11 -0
- package/dist/edge/personalization-core.js +265 -0
- package/dist/next/assignments-script.d.ts +14 -0
- package/dist/next/assignments-script.js +17 -0
- package/dist/next/collect-route.d.ts +19 -0
- package/dist/next/collect-route.js +351 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/middleware.d.ts +15 -1
- package/dist/next/middleware.js +71 -27
- package/dist/next/page.js +2 -1
- package/dist/next/personalization-host.d.ts +118 -0
- package/dist/next/personalization-host.js +276 -0
- package/dist/next/server.js +6 -6
- package/dist/next/vercel-personalization.d.ts +3 -0
- package/dist/next/vercel-personalization.js +3 -0
- package/dist/next/vercel-s3-personalization.d.ts +58 -0
- package/dist/next/vercel-s3-personalization.js +220 -0
- package/dist/providers/s3.d.ts +1 -1
- package/dist/providers/s3.js +1 -1
- package/dist/react/VideoPlayer.d.ts +4 -4
- package/dist/react/index.d.ts +4 -4
- package/dist/types/page.d.ts +1 -1
- package/package.json +24 -16
- package/src/cli/validate-content.mjs +0 -0
- package/src/cli/verify-next-cdn-routes.mjs +5 -4
- package/src/types/page.ts +1 -1
- package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
- package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/astro/starter-content/src/cms/registry.ts +3 -0
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
- package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
- package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
- package/templates/astro/template/src/pages/[...slug].astro +1 -0
- package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
- package/templates/common/.content/audiences/preference-red/_index.json +11 -0
- package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
- package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
- package/templates/common/.content/pages/home/_index.json +114 -4
- package/templates/common/.content/segments/preference-blue/_index.json +20 -0
- package/templates/common/.content/segments/preference-red/_index.json +20 -0
- package/templates/common/contracts/components/index.ts +21 -1
- package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
- package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
- package/templates/nextjs/starter-content/src/middleware.ts +11 -0
- package/templates/nextjs/template/src/app/collect/route.ts +18 -0
- package/templates/nextjs/template/src/middleware.ts +6 -2
- package/templates/astro/template/.aci.yaml +0 -28
- package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
- package/templates/common/.content/config/robots.txt +0 -2
- package/templates/common/.content/config/site.json +0 -197
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
- package/templates/common/.content/fragments/navbar/_index.json +0 -25
- package/templates/common/.content/fragments/site-footer/_index.json +0 -62
- package/templates/common/.content/pages/about/_index.json +0 -51
- 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
|
+
}
|
package/dist/providers/s3.d.ts
CHANGED
|
@@ -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-
|
|
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
|
*/
|
package/dist/providers/s3.js
CHANGED
|
@@ -34,7 +34,7 @@ export class S3ContentProvider {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* getPageByRef serves the exact payload the edge selected (the
|
|
37
|
-
* X-
|
|
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;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/page.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradial/aci",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
149
|
-
"@gradial/aci-cli-darwin-x64": "0.1.
|
|
150
|
-
"@gradial/aci-cli-linux-arm64": "0.1.
|
|
151
|
-
"@gradial/aci-cli-linux-x64": "0.1.
|
|
152
|
-
"@gradial/aci-cli-win32-arm64": "0.1.
|
|
153
|
-
"@gradial/aci-cli-win32-x64": "0.1.
|
|
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
|
-
|
|
56
|
-
'
|
|
55
|
+
checkFile(
|
|
56
|
+
'canonical asset route exists',
|
|
57
57
|
'src/app/gradial/assets/[...path]/route.ts',
|
|
58
|
-
|
|
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
|
-
'
|
|
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
|
@@ -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];
|