@gradial/aci 0.1.38 → 0.1.40

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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/consent-banner/index.d.ts +33 -0
  3. package/dist/consent-banner/index.js +99 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/next/middleware.d.ts +1 -9
  7. package/dist/next/middleware.js +3 -3
  8. package/dist/react/VideoPlayer.d.ts +2 -2
  9. package/dist/react/index.d.ts +2 -2
  10. package/dist/testing/conformance/checks.js +25 -66
  11. package/dist/testing/conformance/preset.js +0 -6
  12. package/dist/testing/conformance/types.d.ts +20 -38
  13. package/package.json +26 -15
  14. package/src/astro/ConsentBanner.astro +62 -0
  15. package/src/cli/validate-content.mjs +0 -0
  16. package/src/consent-banner/styles.css +110 -0
  17. package/templates/astro/template/.aci.yaml +28 -0
  18. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  19. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  20. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  21. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  22. package/templates/common/.content/config/robots.txt +2 -0
  23. package/templates/common/.content/config/site.json +197 -0
  24. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +9 -0
  25. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +14 -0
  26. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +23 -0
  27. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +37 -0
  28. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +9 -0
  29. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +14 -0
  30. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +23 -0
  31. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +37 -0
  32. package/templates/common/.content/fragments/navbar/_index.json +25 -0
  33. package/templates/common/.content/fragments/site-footer/_index.json +62 -0
  34. package/templates/common/.content/pages/about/_index.json +51 -0
  35. package/templates/nextjs/template/.aci.yaml +26 -0
  36. package/templates/nextjs/template/src/middleware.ts +7 -0
  37. package/templates/nextjs/template/src/middleware.ts.tmpl +1 -3
  38. package/templates/nextjs/template/tests/conformance.test.ts.tmpl +1 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.40 - 2026-07-21
4
+
5
+ ### Added
6
+
7
+ - First-party consent banner component (`mountConsentBanner`) for sites without
8
+ an external CMP. Checks for the `aci_consent` cookie, renders a banner with
9
+ accept/reject buttons, and calls `applyConsent()` on user decision.
10
+ - `ConsentBanner.astro` wrapper component for Astro sites, importable from
11
+ `@gradial/aci/astro/ConsentBanner`.
12
+ - Base CSS styles for the consent banner in `@gradial/aci/consent-banner/styles.css`.
13
+ Sites override with their own CSS.
14
+
15
+ ## 0.1.34 - 2026-07-20
16
+
17
+ ### Fixed
18
+
19
+ - Preserve layout-default fragment references during `aci content compile`, so
20
+ shared site chrome such as navigation and footers is not silently omitted.
21
+ - Reject malformed or dangling layout-default fragment references before local
22
+ compilation or managed publishing can produce or stage invalid artifacts.
23
+
24
+ After upgrading, delete any `.aci/compiled` directory produced by an older
25
+ compiler and rerun `aci content compile`. Existing compiled output is derived
26
+ state and is not repaired in place.
@@ -0,0 +1,33 @@
1
+ export interface ConsentBannerOptions {
2
+ /** Heading text. Default: "Privacy" */
3
+ heading?: string;
4
+ /** Body text explaining what the user is consenting to. */
5
+ message?: string;
6
+ /** Label for the accept button. Default: "Accept" */
7
+ acceptLabel?: string;
8
+ /** Label for the reject button. Default: "Reject" */
9
+ rejectLabel?: string;
10
+ /** Label for a link to the privacy policy. Optional. */
11
+ privacyPolicyLabel?: string;
12
+ /** URL to the privacy policy. Optional. */
13
+ privacyPolicyHref?: string;
14
+ /** Source identifier written to the cookie envelope. Default: "banner" */
15
+ source?: string;
16
+ /** Callback fired after a consent decision is written. */
17
+ onDecision?: (granted: boolean) => void;
18
+ }
19
+ /**
20
+ * hasConsentCookie returns true when the aci_consent cookie exists (the user
21
+ * has already made a decision, regardless of whether they granted or denied).
22
+ */
23
+ export declare function hasConsentCookie(doc: {
24
+ cookie: string;
25
+ }): boolean;
26
+ /**
27
+ * mountConsentBanner checks for an existing consent cookie. If none exists,
28
+ * it injects a banner into the document body and wires up accept/reject
29
+ * buttons. If a cookie already exists, it does nothing (idempotent).
30
+ *
31
+ * Returns a cleanup function that removes the banner and event listeners.
32
+ */
33
+ export declare function mountConsentBanner(doc: Document, opts?: ConsentBannerOptions): () => void;
@@ -0,0 +1,99 @@
1
+ // First-party consent banner (design/04, preset="manual"): a self-contained
2
+ // client-side component for sites that don't have an external CMP (c15t,
3
+ // OneTrust, etc.). Checks for the aci_consent cookie, shows a banner if
4
+ // absent, and calls applyConsent() on user decision.
5
+ //
6
+ // The banner is framework-agnostic — it renders plain DOM and is driven by
7
+ // CSS classes so each site can style it. The Astro wrapper component
8
+ // (packages/sdk/src/astro/ConsentBanner.astro) imports and invokes this,
9
+ // then applies site-specific styles.
10
+ import { applyConsent, readConsentCookie, PERSONALIZATION_PURPOSE, } from "../collect/consent/envelope.js";
11
+ const DEFAULTS = {
12
+ heading: "Privacy",
13
+ message: "We use cookies to personalize content and improve your experience. You can accept or reject personalization.",
14
+ acceptLabel: "Accept",
15
+ rejectLabel: "Reject",
16
+ source: "banner",
17
+ };
18
+ const BANNER_ID = "aci-consent-banner";
19
+ const BANNER_ATTR = "data-aci-consent-banner";
20
+ const BANNER_HIDDEN = "aci-consent-banner--hidden";
21
+ /**
22
+ * hasConsentCookie returns true when the aci_consent cookie exists (the user
23
+ * has already made a decision, regardless of whether they granted or denied).
24
+ */
25
+ export function hasConsentCookie(doc) {
26
+ return readConsentCookie(doc) !== undefined;
27
+ }
28
+ /**
29
+ * mountConsentBanner checks for an existing consent cookie. If none exists,
30
+ * it injects a banner into the document body and wires up accept/reject
31
+ * buttons. If a cookie already exists, it does nothing (idempotent).
32
+ *
33
+ * Returns a cleanup function that removes the banner and event listeners.
34
+ */
35
+ export function mountConsentBanner(doc, opts = {}) {
36
+ if (hasConsentCookie(doc))
37
+ return () => { };
38
+ if (doc.getElementById(BANNER_ID))
39
+ return () => { };
40
+ const config = { ...DEFAULTS, ...opts };
41
+ const banner = createBannerElement(config, doc);
42
+ doc.body.appendChild(banner);
43
+ const cleanup = () => {
44
+ banner.remove();
45
+ };
46
+ const decide = (granted) => {
47
+ const purposes = granted ? [PERSONALIZATION_PURPOSE] : [];
48
+ applyConsent(purposes, config.source, { document: doc });
49
+ banner.classList.add(BANNER_HIDDEN);
50
+ setTimeout(cleanup, 300);
51
+ opts.onDecision?.(granted);
52
+ };
53
+ banner.querySelector("[data-aci-consent-accept]")?.addEventListener("click", () => decide(true));
54
+ banner.querySelector("[data-aci-consent-reject]")?.addEventListener("click", () => decide(false));
55
+ return cleanup;
56
+ }
57
+ function createBannerElement(config, doc) {
58
+ const banner = doc.createElement("div");
59
+ banner.id = BANNER_ID;
60
+ banner.setAttribute(BANNER_ATTR, "");
61
+ banner.className = BANNER_ID;
62
+ const content = doc.createElement("div");
63
+ content.className = `${BANNER_ID}__content`;
64
+ const text = doc.createElement("div");
65
+ text.className = `${BANNER_ID}__text`;
66
+ const heading = doc.createElement("p");
67
+ heading.className = `${BANNER_ID}__heading`;
68
+ heading.textContent = config.heading;
69
+ text.appendChild(heading);
70
+ const message = doc.createElement("p");
71
+ message.className = `${BANNER_ID}__message`;
72
+ message.textContent = config.message;
73
+ text.appendChild(message);
74
+ if (config.privacyPolicyHref && config.privacyPolicyLabel) {
75
+ const link = doc.createElement("a");
76
+ link.className = `${BANNER_ID}__privacy-link`;
77
+ link.href = config.privacyPolicyHref;
78
+ link.textContent = config.privacyPolicyLabel;
79
+ text.appendChild(link);
80
+ }
81
+ content.appendChild(text);
82
+ const actions = doc.createElement("div");
83
+ actions.className = `${BANNER_ID}__actions`;
84
+ const rejectBtn = doc.createElement("button");
85
+ rejectBtn.type = "button";
86
+ rejectBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--reject`;
87
+ rejectBtn.setAttribute("data-aci-consent-reject", "");
88
+ rejectBtn.textContent = config.rejectLabel;
89
+ actions.appendChild(rejectBtn);
90
+ const acceptBtn = doc.createElement("button");
91
+ acceptBtn.type = "button";
92
+ acceptBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--accept`;
93
+ acceptBtn.setAttribute("data-aci-consent-accept", "");
94
+ acceptBtn.textContent = config.acceptLabel;
95
+ actions.appendChild(acceptBtn);
96
+ content.appendChild(actions);
97
+ banner.appendChild(content);
98
+ return banner;
99
+ }
package/dist/index.d.ts CHANGED
@@ -15,3 +15,4 @@ export { AssetSchema, isImage, isAsset, } from './types/media.js';
15
15
  export * from './audience-data-models.js';
16
16
  export * from './define-data-model.js';
17
17
  export * from './types/index.js';
18
+ export { mountConsentBanner, hasConsentCookie, type ConsentBannerOptions, } from './consent-banner/index.js';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { AssetSchema, isImage, isAsset, } from './types/media.js';
9
9
  export * from './audience-data-models.js';
10
10
  export * from './define-data-model.js';
11
11
  export * from './types/index.js';
12
+ export { mountConsentBanner, hasConsentCookie, } from './consent-banner/index.js';
12
13
  // Note: content/ is NOT re-exported here because it imports node:path,
13
14
  // making it incompatible with client-side bundlers. Import server-only
14
15
  // content utilities from '@gradial/aci/content' explicitly.
@@ -1,19 +1,11 @@
1
1
  import { type NextRequest } from 'next/server.js';
2
2
  export interface GradialMiddlewareConfig {
3
- /**
4
- * Site identity for this deployment. Env-injected identity is valid —
5
- * the platform sets ACI_SITE_ID per deployment, so passing a bare
6
- * `process.env.ACI_SITE_ID` (which types as `string | undefined`) is
7
- * supported. A missing value 500s cleanly at runtime
8
- * ("ACI site id is not configured").
9
- */
10
- siteId: string | undefined;
11
3
  edgeConfig?: string;
12
4
  previewSignKey?: string;
13
5
  deploymentId?: string;
14
6
  deploymentProtectionBypass?: string;
15
7
  }
16
- export declare function createGradialMiddleware(config: GradialMiddlewareConfig): (request: NextRequest) => Promise<Response>;
8
+ export declare function createGradialMiddleware(config?: GradialMiddlewareConfig): (request: NextRequest) => Promise<Response>;
17
9
  export declare const aciMiddlewareMatcher: string[];
18
10
  export declare const config: {
19
11
  matcher: string[];
@@ -10,7 +10,7 @@ const PASSTHROUGH_ROUTES = (typeof __ACI_PASSTHROUGH_ROUTES__ !== 'undefined' ?
10
10
  const ASSET_PREFIX = typeof __ACI_ASSET_PREFIX__ !== 'undefined'
11
11
  ? __ACI_ASSET_PREFIX__
12
12
  : '/.gradial-dam';
13
- export function createGradialMiddleware(config) {
13
+ export function createGradialMiddleware(config = {}) {
14
14
  return async function middleware(request) {
15
15
  const url = request.nextUrl;
16
16
  // ?leave_preview=1 → clear cookies, redirect to clean URL
@@ -185,8 +185,8 @@ async function activeReleaseFromEdgeConfig(config) {
185
185
  return active.releaseId;
186
186
  return clampRelease(active.releaseId, active.sequence || 0, compatibility);
187
187
  }
188
- function siteIdForConfig(config) {
189
- return config.siteId ?? '';
188
+ function siteIdForConfig(_config) {
189
+ return process.env.ACI_SITE_ID || '';
190
190
  }
191
191
  async function getEdgeConfigJSON(edgeConfig, key) {
192
192
  const value = await getEdgeConfigValue(edgeConfig, key);
@@ -16,10 +16,10 @@ 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
23
  children?: import("react").ReactNode;
24
24
  className?: string | undefined | undefined;
25
25
  crossOrigin?: "" | "anonymous" | "use-credentials" | undefined;
@@ -25,10 +25,10 @@ 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
32
  children?: ReactNode;
33
33
  className?: string | undefined | undefined;
34
34
  crossOrigin?: "" | "anonymous" | "use-credentials" | undefined;
@@ -89,17 +89,25 @@ function walkFiles(dir, predicate) {
89
89
  // ---------------------------------------------------------------------------
90
90
  export function checkConfig(ctx) {
91
91
  const results = [];
92
- // Env-only site identity: in siteIdMode 'env' the platform owns tenant
93
- // identity (terraform injects ACI_SITE_ID per deployment), so .aci.yaml
94
- // must not carry a siteId at all — a leftover label pins the repo to one
95
- // site and drifts silently from the deployed identity.
96
- if (ctx.siteIdMode === 'env') {
92
+ // Validate .aci.yaml siteId: when ctx.siteId is provided, verify it matches;
93
+ // when not provided, verify .aci.yaml does not pin a siteId at all.
94
+ {
97
95
  const aciYaml = readTextFile(join(ctx.rootDir, '.aci.yaml'));
98
96
  if (aciYaml !== null) {
99
- const pinsSiteId = /^siteId\s*:/m.test(aciYaml);
100
- results.push(!pinsSiteId
101
- ? ok('config', '.aci.yaml does not pin a siteId (env-only identity)')
102
- : fail('config', '.aci.yaml does not pin a siteId (env-only identity)', '.aci.yaml declares siteId but this repo uses siteIdMode "env": the platform owns site identity (ACI_SITE_ID is injected per deployment), so the manifest must not pin a site — delete the siteId line'));
97
+ const siteIdMatch = aciYaml.match(/^siteId\s*:\s*["']?([^"'\n]+)["']?\s*$/m);
98
+ if (ctx.siteId) {
99
+ const declared = siteIdMatch ? siteIdMatch[1].trim() : null;
100
+ results.push(declared === ctx.siteId
101
+ ? ok('config', '.aci.yaml siteId matches expected value')
102
+ : fail('config', '.aci.yaml siteId matches expected value', declared
103
+ ? `.aci.yaml siteId is '${declared}' but expected '${ctx.siteId}'`
104
+ : `.aci.yaml is missing siteId (expected '${ctx.siteId}')`));
105
+ }
106
+ else {
107
+ results.push(!siteIdMatch
108
+ ? ok('config', '.aci.yaml does not pin a siteId')
109
+ : fail('config', '.aci.yaml does not pin a siteId', '.aci.yaml declares siteId but no expected value was provided — either pass siteId to the preset or remove it from .aci.yaml'));
110
+ }
103
111
  }
104
112
  }
105
113
  if (ctx.framework === 'next') {
@@ -255,65 +263,16 @@ export function checkMiddleware(ctx) {
255
263
  results.push(usesSDKMiddleware
256
264
  ? ok('middleware', 'middleware uses createGradialMiddleware', rel)
257
265
  : fail('middleware', 'middleware uses createGradialMiddleware', rel + ' should use createGradialMiddleware from @gradial/aci/next/middleware'));
258
- // Check that the siteId passed to createGradialMiddleware is canonical.
259
- // Accepted forms depend on ctx.siteIdMode (default 'literal'):
260
- //
261
- // 'literal':
262
- // (a) a string literal exactly equal to ctx.siteId (the tenant's
263
- // canonical site id) — this is what the template stamps;
264
- // (b) process.env.ACI_SITE_ID || '<literal>' (or ?? instead of ||)
265
- // where the literal default is exactly ctx.siteId — the
266
- // multi-tenant/dev-mirror form: the deployment env supplies the
267
- // per-tenant identity, the default keeps local dev correct.
268
- //
269
- // 'env' (tenant-agnostic repos deployed to multiple sites): the ONLY
270
- // accepted form is exactly `siteId: process.env.ACI_SITE_ID`. The
271
- // platform owns tenant identity — terraform sets ACI_SITE_ID on every
272
- // deployment — and the runtime 500s cleanly if it is missing, so any
273
- // literal, fallback (|| / ??), other env var name, or computed
274
- // expression fails.
275
- //
276
- // Anything else (placeholder literal, wrong/missing default, other env
277
- // var names, computed expressions) fails: a wrong siteId means the edge
278
- // release-pointer key never matches and every live route 503s.
279
- const siteIdMode = ctx.siteIdMode ?? 'literal';
280
- if (siteIdMode === 'env') {
281
- const label = 'middleware configures canonical siteId';
282
- // Narrow the search to the createGradialMiddleware call when present so
283
- // an unrelated siteId reference elsewhere in the file can't satisfy us.
284
- const callIdx = middlewareFile.content.indexOf('createGradialMiddleware(');
285
- const region = callIdx >= 0 ? middlewareFile.content.slice(callIdx) : middlewareFile.content;
286
- // Exactly `siteId: process.env.ACI_SITE_ID` followed by the end of the
287
- // property (`,` or `}`) — same object-property shape the literal-mode
288
- // regexes target, but with nothing allowed after the env access.
289
- const bareEnv = /siteId\s*:\s*process\.env\.ACI_SITE_ID\s*(?=[,}])/.test(region);
290
- results.push(bareEnv
291
- ? ok('middleware', label, `${rel} (env-only siteId)`)
292
- : fail('middleware', label, `${rel}: middleware must read siteId only from process.env.ACI_SITE_ID (no literal or fallback) — the platform owns tenant identity and injects ACI_SITE_ID per deployment`));
293
- }
294
- else if (ctx.siteId) {
295
- const label = 'middleware configures canonical siteId';
296
- const expected = ctx.siteId;
297
- const acceptedForms = `accepted forms: siteId: '${expected}' or ` +
298
- `siteId: process.env.ACI_SITE_ID || '${expected}' (?? also accepted)`;
299
- // Narrow the search to the createGradialMiddleware call when present so
300
- // an unrelated siteId reference elsewhere in the file can't satisfy us.
266
+ // Middleware must NOT pass a siteId — the SDK reads process.env.ACI_SITE_ID
267
+ // internally. Any siteId arg is stale config that should be removed.
268
+ {
269
+ const label = 'middleware does not pass siteId';
301
270
  const callIdx = middlewareFile.content.indexOf('createGradialMiddleware(');
302
271
  const region = callIdx >= 0 ? middlewareFile.content.slice(callIdx) : middlewareFile.content;
303
- // Form (b): process.env.ACI_SITE_ID || '<default>' (or ??).
304
- const envMatch = region.match(/siteId\s*:\s*process\.env\.ACI_SITE_ID\s*(?:\|\||\?\?)\s*(['"`])((?:(?!\1).)*)\1/);
305
- // Form (a): bare string literal (single/double quotes or backticks).
306
- const literalMatch = region.match(/siteId\s*:\s*(['"`])((?:(?!\1).)*)\1/);
307
- const configured = envMatch ? envMatch[2] : literalMatch ? literalMatch[2] : null;
308
- if (configured === expected) {
309
- results.push(ok('middleware', label, `${rel} (${envMatch ? 'env with canonical default' : 'canonical literal'})`));
310
- }
311
- else if (configured !== null) {
312
- results.push(fail('middleware', label, `${rel}: siteId ${envMatch ? 'default ' : ''}is '${configured}' but the canonical site id is '${expected}'; ${acceptedForms}`));
313
- }
314
- else {
315
- results.push(fail('middleware', label, `${rel}: siteId passed to createGradialMiddleware is not an accepted form (placeholder/computed expressions, other env vars, and process.env.ACI_SITE_ID without a literal default all fail); ${acceptedForms}`));
316
- }
272
+ const passesSiteId = /siteId\s*:/.test(region);
273
+ results.push(!passesSiteId
274
+ ? ok('middleware', label, rel)
275
+ : fail('middleware', label, `${rel}: remove siteId from createGradialMiddleware() — the SDK reads process.env.ACI_SITE_ID internally; the platform injects it per deployment`));
317
276
  }
318
277
  // Check that the middleware matcher is inlined (not imported) and matches
319
278
  // the SDK's canonical value. Next.js requires config.matcher to be
@@ -56,11 +56,8 @@ function resolveChecks(input) {
56
56
  export function runConformanceChecks(options) {
57
57
  const ctx = {
58
58
  framework: options.framework,
59
- // 'env' mode carries no site identity in the repo; checks never compare
60
- // against a value, so the context uses the empty string.
61
59
  siteId: options.siteId ?? '',
62
60
  rootDir: options.rootDir || process.cwd(),
63
- siteIdMode: options.siteIdMode || 'literal',
64
61
  };
65
62
  const checks = resolveChecks(options.checks);
66
63
  const allResults = checks.flatMap((name) => {
@@ -85,11 +82,8 @@ export function conformancePreset(options) {
85
82
  test('ACI conformance', async (t) => {
86
83
  const ctx = {
87
84
  framework: options.framework,
88
- // 'env' mode carries no site identity in the repo; checks never compare
89
- // against a value, so the context uses the empty string.
90
85
  siteId: options.siteId ?? '',
91
86
  rootDir: options.rootDir || process.cwd(),
92
- siteIdMode: options.siteIdMode || 'literal',
93
87
  };
94
88
  for (const checkName of checks) {
95
89
  await t.test(checkName, () => {
@@ -8,10 +8,27 @@
8
8
  export type ConformanceFramework = 'next' | 'astro';
9
9
  /** Individual check names that can be enabled/disabled. */
10
10
  export type ConformanceCheckName = 'config' | 'registry' | 'pageEntry' | 'middleware' | 'noFileContentProvider' | 'noCustomContent' | 'noLegacyAssetRoute' | 'buildSucceeds' | 'contentCompile' | 'sdkVersion';
11
- /** Options common to both siteId modes. */
12
- export interface ConformancePresetCommonOptions {
11
+ /**
12
+ * Options for the conformance preset.
13
+ *
14
+ * Middleware must always read siteId from `process.env.ACI_SITE_ID` — the
15
+ * platform owns tenant identity and injects it per deployment. Literals,
16
+ * fallbacks (`||`/`??`), and other env var names all fail. A missing env
17
+ * produces a clean 500 at runtime.
18
+ *
19
+ * `siteId` is optional — when provided, .aci.yaml's siteId is validated
20
+ * against it. When omitted, the check verifies .aci.yaml does not pin a
21
+ * siteId at all.
22
+ */
23
+ export interface ConformancePresetOptions {
13
24
  /** Framework the site uses. */
14
25
  framework: ConformanceFramework;
26
+ /**
27
+ * Expected canonical siteId in .aci.yaml. When provided, the config check
28
+ * verifies .aci.yaml declares this exact value. When omitted, the config
29
+ * check verifies .aci.yaml does NOT declare a siteId.
30
+ */
31
+ siteId?: string;
15
32
  /** Root directory of the site project. Defaults to process.cwd(). */
16
33
  rootDir?: string;
17
34
  /**
@@ -20,36 +37,6 @@ export interface ConformancePresetCommonOptions {
20
37
  */
21
38
  checks?: ConformanceCheckName[] | Partial<Record<ConformanceCheckName, boolean>>;
22
39
  }
23
- /**
24
- * Options for the conformance preset.
25
- *
26
- * The siteId requirement is keyed off `siteIdMode` (defaults to 'literal'):
27
- *
28
- * - 'literal': `siteId` is REQUIRED — it is the tenant's canonical site id,
29
- * and the middleware must pass it as a string literal (or
30
- * `process.env.ACI_SITE_ID || '<canonical>'` / `??` with the canonical
31
- * literal as the default). This is what the template stamps for
32
- * single-tenant repos.
33
- * - 'env': `siteId` is FORBIDDEN — tenant-agnostic repos deployed to
34
- * multiple sites have no site identity in the repo at all. The middleware
35
- * must read siteId ONLY from the environment — exactly
36
- * `siteId: process.env.ACI_SITE_ID`, with no literal default, no
37
- * `||`/`??` fallback, no other env var names, and no computed
38
- * expressions — and `.aci.yaml` must not pin a siteId. The platform owns
39
- * tenant identity and injects ACI_SITE_ID per deployment; a missing env
40
- * produces a clean 500 at runtime.
41
- */
42
- export type ConformancePresetOptions = ConformancePresetCommonOptions & ({
43
- /** See ConformancePresetOptions: 'literal' expects a canonical siteId in the repo. */
44
- siteIdMode?: 'literal';
45
- /** Expected canonical siteId value (verified in config and middleware). */
46
- siteId: string;
47
- } | {
48
- /** See ConformancePresetOptions: 'env' means the repo carries no site identity. */
49
- siteIdMode: 'env';
50
- /** Forbidden in env mode — the repo must not carry a site identity. */
51
- siteId?: never;
52
- });
53
40
  /** Result of a single conformance check. */
54
41
  export interface ConformanceCheckResult {
55
42
  name: ConformanceCheckName;
@@ -68,12 +55,7 @@ export type ConformanceCheckFn = (ctx: ConformanceContext) => ConformanceCheckRe
68
55
  /** Context passed to each check function. */
69
56
  export interface ConformanceContext {
70
57
  framework: ConformanceFramework;
71
- /**
72
- * Canonical siteId in 'literal' mode; '' in 'env' mode (no site identity
73
- * in the repo — env-mode checks never compare against a value).
74
- */
58
+ /** Canonical siteId from options, or '' when not provided. */
75
59
  siteId: string;
76
60
  rootDir: string;
77
- /** See ConformancePresetOptions.siteIdMode. Defaults to 'literal'. */
78
- siteIdMode?: 'literal' | 'env';
79
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -9,9 +9,12 @@
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",
17
+ "CHANGELOG.md",
15
18
  "README.md"
16
19
  ],
17
20
  "bin": {
@@ -83,11 +86,26 @@
83
86
  "import": "./dist/astro/index.js",
84
87
  "default": "./dist/astro/index.js"
85
88
  },
89
+ "./astro/ConsentBanner": {
90
+ "import": "./src/astro/ConsentBanner.astro"
91
+ },
92
+ "./consent-banner": {
93
+ "types": "./dist/consent-banner/index.d.ts",
94
+ "import": "./dist/consent-banner/index.js"
95
+ },
86
96
  "./testing": {
87
97
  "types": "./dist/testing/conformance/index.d.ts",
88
98
  "import": "./dist/testing/conformance/index.js"
89
99
  }
90
100
  },
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
+ },
91
109
  "dependencies": {
92
110
  "markdown-it": "^14.1.0",
93
111
  "sanitize-html": "^2.13.1",
@@ -127,18 +145,11 @@
127
145
  "zod": "^4.0.0"
128
146
  },
129
147
  "optionalDependencies": {
130
- "@gradial/aci-cli-darwin-arm64": "0.1.38",
131
- "@gradial/aci-cli-darwin-x64": "0.1.38",
132
- "@gradial/aci-cli-linux-arm64": "0.1.38",
133
- "@gradial/aci-cli-linux-x64": "0.1.38",
134
- "@gradial/aci-cli-win32-arm64": "0.1.38",
135
- "@gradial/aci-cli-win32-x64": "0.1.38"
136
- },
137
- "scripts": {
138
- "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
139
- "test": "node --import tsx --test \"src/**/*.test.ts\"",
140
- "build:cli": "cd ../.. && ./scripts/build-cli.sh",
141
- "generate-starters": "cd ../.. && tsx scripts/generate-starters.ts",
142
- "compile-registry": "tsx src/cli/compile-registry.mjs"
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"
143
154
  }
144
- }
155
+ }
@@ -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>
File without changes